Compare commits

..
Author SHA1 Message Date
Codex 2ac36a6ca5 fix(mcp): защитить операции обслуживания от блокировок строк 2026-11-13 19:10:50 +07:00
Codex cffcfada88 feat(mcp): расширить управление маршрутизацией, доступом и обслуживанием базы 2026-11-13 13:37:55 +07:00
Codex 9240e8be11 fix(netflow): связать отметки потока SSE с диагностикой NetFlow 2026-11-12 17:46:59 +07:00
Denozordec d79fb3815a fix(mcp): упорядочить записи журнала опроса 2026-11-12 10:23:34 +07:00
Codex 765b9a1658 fix(netflow): читать активные записи на одном снимке 2026-11-12 08:40:06 +07:00
Codex 5d181e7cb0 fix(netflow): пропустить загрузку флага неизвестной страны 2026-11-11 23:46:51 +07:00
Codex cf211f7418 fix(netflow): восстановить открытие потока после временного ограничения 2026-11-11 22:54:42 +07:00
Codex 04225886e0 fix(mcp): довести базовые сценарии и проверку маршрутизации 2026-11-11 20:28:37 +07:00
Denozordec 1f0704add0 fix(mcp): включить проверяемый контроллер маршрутизации 2026-11-05 05:36:19 +07:00
Codex 6569dbfc60 fix(mcp): привязать OAuth-согласие и завершать чтение логов при обслуживании 2026-11-05 03:56:17 +07:00
Denozordec 78b268cc39 fix(wireguard): безопасно повторить чтение зависимостей 2026-11-05 01:39:26 +07:00
Denozordec 3d5b7f0ea6 fix(traffic): загружать данные потоков последовательно 2026-11-04 23:51:03 +07:00
Denozordec b4d2d4bc20 fix(mcp): ограничить очередь NetFlow и защитить чтение истории 2026-11-04 21:58:22 +07:00
239 changed files with 14080 additions and 1300 deletions

No files matched your search

+9 -6
View File
@@ -924,18 +924,21 @@ export default function TrafficPage() {
})
return () => { cancelled = true }
}
void Promise.all([
getTrafficFlows(backendUrl, range),
getFlowExporters(backendUrl, range),
getFlowClients(backendUrl, range),
]).then(([stats, exporters, clients]) => {
const loadFlowCards = async () => {
// These reads share the collector's constrained admission budget.
const stats = await getTrafficFlows(backendUrl, range)
if (cancelled) return
const exporters = await getFlowExporters(backendUrl, range)
if (cancelled) return
const clients = await getFlowClients(backendUrl, range)
if (cancelled) return
setFlowResult({ key: flowKey, receivedAt: Date.now(), stats, exporters: exporters.exporters, clients: clients.clients })
setSelectedId(prev => {
const list = flowScope === "users" ? clients.clients : exporters.exporters
return list.some(x => x.id === prev) ? prev : (list[0]?.id ?? "")
})
}).catch(e => {
}
void loadFlowCards().catch(e => {
if (!cancelled) setFlowResult({ key: flowKey, error: e instanceof Error ? e.message : "Не удалось загрузить потоки" })
})
return () => { cancelled = true }
+4 -2
View File
@@ -74,6 +74,7 @@ function UsersContent() {
const isLive = mode === "live"
const canRead = can("mm:users:read")
const canWrite = can("mm:users:write")
const canManagePermissions = !isLive || can("mm:settings:admin")
const canReadServers = can("mm:servers:read")
const [users, setUsers] = useState<AppUser[]>(() => isLive ? [] : INIT_USERS)
@@ -184,8 +185,8 @@ function UsersContent() {
setSaving(true); setSaveError(null); setOperationNotice(null)
try {
const saved = await saveUserSteps(form, checkpoint, {
create: f => createAppUser(backendUrl, userProfileRequest(f), init()),
update: (id, f) => updateAppUser(backendUrl, id, userProfileRequest(f), init()),
create: f => createAppUser(backendUrl, userProfileRequest(f, canManagePermissions), init()),
update: (id, f) => updateAppUser(backendUrl, id, userProfileRequest(f, canManagePermissions), init()),
read: id => getAppUser(backendUrl, id, init()),
removeBinding: (userId, id) => deleteUserBinding(backendUrl, userId, id, init()),
addBinding: (userId, b) => createUserBinding(backendUrl, userId, {
@@ -328,6 +329,7 @@ function UsersContent() {
users={users}
servers={serverOptions}
isLive={isLive}
canManagePermissions={canManagePermissions}
backendUrl={backendUrl}
saving={saving}
saveError={saveError}
+10 -1
View File
@@ -1,7 +1,16 @@
@import "tailwindcss";
@import "tailwindcss" source(none);
@import "tw-animate-css";
@import "shadcn/tailwind.css";
/* Scan application sources only; local test archives and generated bundles
must not become utility classes in the production stylesheet. */
@source "./";
@source "../components";
@source "../entities";
@source "../hooks";
@source "../lib";
@source "../shared";
/* SVG focus follows the object shape, never the text/foreignObject bounding box. */
.network-map-focusable {
outline: none;
@@ -0,0 +1,43 @@
-- Keep the live charge and intent fences in sync with recovery action v8.
-- Existing authenticated v6/v7 history remains unchanged.
BEGIN;
CREATE OR REPLACE FUNCTION public.mcp_routing_manual_inverse_charge_source() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NOT EXISTS(
SELECT 1 FROM public.mcp_operations o
JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
JOIN public.mcp_routing_profiles r ON r.id=NEW.profile_id AND r.connection_id=o.connection_id
JOIN public.mcp_operations source ON source.id=NEW.source_operation_id AND source.connection_id=o.connection_id
JOIN public.mcp_plans sp ON sp.id=source.plan_id AND sp.connection_id=source.connection_id
JOIN public.mcp_connections c ON c.id=o.connection_id
WHERE o.id=NEW.recovery_operation_id AND p.id=NEW.recovery_plan_id
AND o.connection_id=NEW.connection_id AND c.owner_id=NEW.owner_id
AND r.version=NEW.profile_version AND o.state='pending' AND p.state='executing'
AND p.operation_id='operations.recover' AND p.operation_version=8
AND sp.id=NEW.source_plan_id AND sp.operation_id='routing.community.switch' AND sp.operation_version=7
AND source.state IN('applied','partial')
) THEN RAISE EXCEPTION 'manual inverse charge differs from pending admission'; END IF;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.mcp_scoped_recovery_manual_version() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.mode='manual' AND NOT pg_try_advisory_xact_lock(hashtextextended('mcp-runner-admission',0))
THEN RAISE EXCEPTION 'manual intent admission snapshot busy'; END IF;
IF NEW.mode='manual' AND NOT EXISTS(
SELECT 1 FROM public.mcp_operations o
JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
JOIN public.mcp_operation_steps s ON s.operation_id=o.id AND s.step_index=0
JOIN public.mcp_operations source ON source.id=NEW.source_operation_id AND source.connection_id=o.connection_id
WHERE o.id=NEW.recovery_operation_id AND o.state='running'
AND p.state='executing' AND p.operation_id='operations.recover' AND p.operation_version=8
AND NEW.source_step_index=0 AND s.state='intent'
AND s.intended_state->>'kind'='service'
AND s.intended_state->>'service'='routing.community.scoped.recover'
AND s.intended_state->'input'->>'mode'='manual'
AND s.intended_state->'input'->>'sourceOperationId'=NEW.source_operation_id::text
AND s.intended_state->'input'->>'receiptId'=NEW.recovery_receipt_id::text
) THEN RAISE EXCEPTION 'manual inverse requires current exact running recovery'; END IF;
RETURN NEW;
END $$;
INSERT INTO public.schema_migrations(id) VALUES('0056_mcp_manual_inverse_recovery_v8') ON CONFLICT(id) DO NOTHING;
COMMIT;
+14
View File
@@ -597,6 +597,13 @@
"operations": [],
"reason": "Управление самим MCP, транспорт или машинная авторизация: отдельного AI-инструмента нет."
},
{
"method": "GET",
"path": "/api/mcp/devices/:serverId/bridge-vlan/configuration",
"status": "mediated",
"operations": ["network.bridge-vlan.read"],
"reason": "Чтение связанной конфигурации bridge/VLAN через общую типизированную модель и проверку доступа MCP."
},
{
"method": "GET",
"path": "/api/mcp/drift",
@@ -1611,6 +1618,13 @@
"operations": [],
"reason": "Управление самим MCP, транспорт или машинная авторизация: отдельного AI-инструмента нет."
},
{
"method": "POST",
"path": "/api/mcp/devices/:serverId/bridge-vlan/configuration/plan",
"status": "mediated",
"operations": ["network.bridge-vlan.configure"],
"reason": "Подготовка связанного плана bridge/VLAN через тот же MCP-инструмент; выполнение требует человеческого подтверждения."
},
{
"method": "POST",
"path": "/api/mcp/management-channels",
+7 -4
View File
@@ -7,7 +7,10 @@
"test:mcp:module-completion": "tsx src/services/ipsec-server-init-model.test.ts && tsx src/modules/mcp/ipsec-server-init-actions.test.ts && tsx src/services/wireguard-revision-restore.test.ts && tsx src/modules/mcp/vrf-ticket-runtime.test.ts && tsx src/services/ipsec-restore.test.ts && tsx src/services/ipsec-pool-dependencies.test.ts && tsx src/services/vrf-production-transition.test.ts && tsx src/services/vrf-production-action-binding.test.ts && tsx src/services/ipsec-revision-restore-model.test.ts && tsx src/modules/mcp/ipsec-revision-actions.test.ts && tsx src/modules/mcp/service-native-progress.test.ts && tsx src/services/ipsec-revision-restore.test.ts && tsx src/services/ipsec-revision-secret.test.ts",
"test:mcp:module-completion:pg": "node scripts/test-mcp-postgres.mjs --module-completion --restore",
"test:mcp:pg": "node scripts/test-mcp-postgres.mjs",
"test:mcp:users-bindings:pg": "node scripts/test-mcp-postgres.mjs --users-bindings",
"test:mcp:bridge-vlan:pg": "node scripts/test-mcp-postgres.mjs --bridge-vlan-configuration",
"test:mcp:external-process:pg": "node scripts/mcp-external-process/run-owned.mjs",
"test:mcp:bridge-vlan:sdk": "node scripts/mcp-external-process/run-owned.mjs .. bridge-vlan",
"test:traffic-flow:ownership": "tsx src/services/traffic-flow-batch-proof.test.ts && tsx src/services/traffic-flow-owned-batch.test.ts && tsx src/services/traffic-flow-owned-writer.test.ts && tsx src/services/traffic-flow-write-transaction.test.ts && tsx src/services/traffic-flow-writer-exclusion.test.ts && tsx src/services/traffic-flow-drain-protocol.test.ts && tsx src/services/traffic-flow-facts-rebuild-execution.test.ts && tsx src/services/traffic-flow-facts-rebuild-source.test.ts && tsx src/services/traffic-flow-facts-rebuild-classifier.test.ts && tsx src/services/traffic-flow-facts-rebuild-service.test.ts && tsx src/services/traffic-flow-ripe-prune-owned.test.ts && tsx src/services/traffic-flow-engine-owned.test.ts && tsx src/routes/traffic-flow-rebuild.test.ts && tsx src/services/traffic-flow-settings-execution.test.ts",
"test:traffic-flow:rebuild:pg": "node scripts/test-mcp-postgres.mjs --flow-rebuild",
"test:traffic-flow:pg": "node scripts/test-mcp-postgres.mjs --flow-writes",
@@ -34,11 +37,11 @@
"test:probes": "npm run test:speed-workflow && tsx src/services/manual-data-collection-execution.test.ts && tsx src/routes/manual-data-collection.test.ts && tsx src/services/network-host-resolution.test.ts && tsx src/routes/probes.test.ts && tsx src/routes/probes.cancel.test.ts && tsx src/services/ping-measurement.test.ts && tsx src/services/uptime-probe-observations.test.ts && tsx src/services/ping-measurement-sql.test.ts",
"test:exec": "tsx src/services/mikrotik-logs.test.ts && tsx src/services/terminal-diagnostics.test.ts && tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts",
"test:auth": "tsx --test src/modules/mcp/routing-manual-inverse-history.test.ts && tsx src/db/application-writer-profile-registry-structure.test.ts && tsx src/db/application-writer-profile-registry-read.test.ts && tsx src/db/application-writer-profile-registry-read-parity.test.ts && tsx src/db/application-writer-profile-format.test.ts && tsx src/plugins/auth-path.test.ts && tsx src/lib/permissions.test.ts && tsx src/plugins/auth-provenance.test.ts && tsx src/services/bandwidth-rest-speed-original.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts && npm run test:mcp",
"test:mcp": "tsx --test src/services/server-poll-error-provenance.test.ts && tsx --test src/modules/mcp/plan-expiry.test.ts src/modules/mcp/result-budget.test.ts src/modules/mcp/result-delivery.test.ts src/modules/mcp/wire-admission.test.ts src/modules/mcp/flow-envelope-allowance.test.ts src/modules/mcp/sdk-terminal-budget.test.ts src/modules/mcp/inflight-owner.test.ts && npm run test:database-source-binding && tsx --test src/modules/mcp/integration-fixture-expiry.test.ts && npm run test:mcp:operation-origin && npm run test:mcp:service-context && npm run test:mcp:operation-locks && tsx --test src/modules/mcp/routing-community-public.test.ts src/modules/mcp/management-transport.test.ts src/modules/mcp/routing-community-context-model.test.ts src/modules/mcp/routing-community-context-native.test.ts ../packages/contracts/src/mcp-routing.test.ts && tsx src/modules/mcp/human-artifact-receipts.test.ts && tsx src/modules/mcp/chr-overlay-readiness.test.ts && tsx src/modules/mcp/chr-recovery-source.test.ts && tsx src/modules/mcp/chr-native-failure.test.ts && tsx src/modules/mcp/ospf-owner-graph.test.ts && tsx src/modules/mcp/routing-measurement-address.test.ts && tsx src/modules/mcp/routing-observation-source.test.ts && tsx src/services/traffic-flow-talkers-model.test.ts && tsx src/services/bgp-prefix-model.test.ts && tsx src/services/vrf-members.test.ts && tsx src/services/routing-gateway-reference.test.ts && tsx src/services/bgp-export-model.test.ts && tsx src/services/traffic-history-model.test.ts && tsx src/services/internet-path-snapshot.test.ts && tsx src/services/internet-path-observation.test.ts && tsx src/modules/mcp/coverage.test.ts && tsx src/modules/mcp/policy.test.ts && tsx src/modules/mcp/events-batch-model.test.ts && tsx src/modules/mcp/operation-revisions.test.ts && tsx src/modules/mcp/native-create-identity.test.ts && tsx src/modules/mcp/ros-step-verification.test.ts && tsx src/modules/mcp/ros-prefix.test.ts && tsx src/modules/mcp/native-conservation-http.test.ts && tsx src/modules/mcp/security.test.ts && tsx src/modules/mcp/state-file-publish.test.ts && tsx src/modules/mcp/routing-model.test.ts && tsx src/modules/mcp/routing-observations-model.test.ts && tsx src/modules/mcp/ospf-health.test.ts && tsx src/modules/mcp/ecmp-preflight.test.ts && tsx src/modules/mcp/recursive-recovery.test.ts && tsx src/modules/mcp/control-path-model.test.ts && tsx src/modules/mcp/routing-community-scoped-model.test.ts && tsx src/modules/mcp/routing-community-scoped-native.test.ts && tsx src/modules/mcp/path-verification.test.ts && tsx src/modules/mcp/database-maintenance.test.ts && tsx src/routes/mcp.test.ts && tsx ../lib/auth-mcp.test.ts && tsx --test src/modules/mcp/routing-controller-fence.test.ts && tsx src/test/chr-vrf-companion-proof.test.ts && tsx src/test/chr-vrf-companion-telemetry.test.ts && tsx src/test/chr-vrf-companion-address-space.test.ts && tsx src/modules/mcp/flow-facts-rebuild-actions.test.ts && tsx src/modules/mcp/ipsec-peer-create-dependencies.test.ts && tsx src/services/ipsec-certificate-update-model.test.ts && tsx src/modules/mcp/ipsec-native-dispatch.test.ts && tsx src/modules/mcp/ipsec-user-update-actions.test.ts && tsx src/modules/mcp/ipsec-server-delete-actions.test.ts && tsx src/services/ipsec-proof-telemetry.test.ts && tsx src/services/vrf-production-admission.test.ts && tsx src/modules/mcp/flow-host-keys-actions.test.ts && tsx src/services/vrf-production-read.test.ts && tsx src/modules/mcp/ipsec-user-create-actions.test.ts && tsx src/modules/mcp/flow-settings-actions.test.ts && tsx src/services/vrf-production-ticket-store.test.ts && npm run test:mcp:routing-baseline",
"test:wireguard": "tsx src/services/mikrotik-response-bounds.test.ts && tsx src/services/wireguard-interface-update.test.ts && tsx src/services/wireguard-peer-update.test.ts && npx tsx src/services/wireguard-config.test.ts && npx tsx src/services/wireguard-ipv6.test.ts && npx tsx --test src/services/wireguard-import.test.ts src/services/wireguard-import-apply.test.ts src/services/wireguard-import-revision.test.ts src/services/wireguard-delete.test.ts && npx tsx src/routes/wireguard-import.test.ts && tsx src/services/wireguard-interface-create.test.ts && tsx src/services/wireguard-peer-create.test.ts && tsx src/services/wireguard-import-plan.test.ts && tsx src/services/wireguard-import-execution.test.ts && tsx src/modules/mcp/wireguard-import-actions.test.ts && tsx --test src/services/wireguard-export.test.ts",
"test:mcp": "node scripts/vrf-production-profile.test.mjs && node src/services/vrf-production-peer-telemetry.test.mjs && node src/services/vrf-execution-diagnostic.test.mjs && node src/services/vrf-transition-refusal.test.mjs && node src/services/vrf-production-gre-cycle.test.mjs && tsx src/services/vrf-production-gre-link-local.test.ts && tsx src/services/vrf-production-bridge-delete.test.ts && tsx src/services/vrf-production-transition.test.ts && tsx --test src/services/routeros-snapshot-read.test.ts src/services/bridge-vlan-configuration.test.ts src/modules/mcp/bridge-vlan-projection.test.ts src/modules/mcp/vrf-preparation-diagnostic.test.ts && tsx --test src/services/server-poll-error-provenance.test.ts && tsx --test src/modules/mcp/plan-expiry.test.ts src/modules/mcp/result-budget.test.ts src/modules/mcp/result-delivery.test.ts src/modules/mcp/wire-admission.test.ts src/modules/mcp/flow-envelope-allowance.test.ts src/modules/mcp/sdk-terminal-budget.test.ts src/modules/mcp/inflight-owner.test.ts && npm run test:database-source-binding && tsx --test src/modules/mcp/integration-fixture-expiry.test.ts && npm run test:mcp:operation-origin && npm run test:mcp:service-context && npm run test:mcp:operation-locks && tsx --test src/modules/mcp/routing-community-public.test.ts src/modules/mcp/management-transport.test.ts src/modules/mcp/routing-community-context-model.test.ts src/modules/mcp/routing-community-context-native.test.ts ../packages/contracts/src/mcp-routing.test.ts && tsx src/modules/mcp/human-artifact-receipts.test.ts && tsx src/modules/mcp/chr-overlay-readiness.test.ts && tsx src/modules/mcp/chr-recovery-source.test.ts && tsx src/modules/mcp/chr-native-failure.test.ts && tsx src/modules/mcp/ospf-owner-graph.test.ts && tsx src/modules/mcp/routing-measurement-address.test.ts && tsx src/modules/mcp/routing-observation-source.test.ts && tsx src/services/traffic-flow-talkers-model.test.ts && tsx src/services/bgp-prefix-model.test.ts && tsx src/services/vrf-members.test.ts && tsx src/services/routing-gateway-reference.test.ts && tsx src/services/bgp-export-model.test.ts && tsx src/services/traffic-history-model.test.ts && tsx src/services/internet-path-snapshot.test.ts && tsx src/services/internet-path-observation.test.ts && tsx src/modules/mcp/coverage.test.ts && tsx src/modules/mcp/policy.test.ts && tsx src/modules/mcp/events-batch-model.test.ts && tsx src/modules/mcp/operation-revisions.test.ts && tsx src/modules/mcp/native-create-identity.test.ts && tsx src/modules/mcp/ros-step-verification.test.ts && tsx src/modules/mcp/ros-prefix.test.ts && tsx src/modules/mcp/native-conservation-http.test.ts && tsx src/modules/mcp/security.test.ts && tsx src/modules/mcp/state-file-publish.test.ts && tsx src/modules/mcp/routing-model.test.ts && tsx src/modules/mcp/routing-observations-model.test.ts && tsx src/modules/mcp/ospf-health.test.ts && tsx src/modules/mcp/ecmp-preflight.test.ts && tsx src/modules/mcp/recursive-recovery.test.ts && tsx src/modules/mcp/control-path-model.test.ts && tsx src/modules/mcp/routing-community-scoped-model.test.ts && tsx src/modules/mcp/routing-community-scoped-native.test.ts && tsx src/modules/mcp/path-verification.test.ts && tsx src/modules/mcp/database-maintenance.test.ts && tsx src/routes/mcp.test.ts && tsx ../lib/auth-mcp.test.ts && tsx --test src/modules/mcp/routing-controller-fence.test.ts && tsx src/test/chr-vrf-companion-proof.test.ts && tsx src/test/chr-vrf-companion-telemetry.test.ts && tsx src/test/chr-vrf-companion-address-space.test.ts && tsx src/modules/mcp/flow-facts-rebuild-actions.test.ts && tsx src/modules/mcp/ipsec-peer-create-dependencies.test.ts && tsx src/services/ipsec-certificate-update-model.test.ts && tsx src/modules/mcp/ipsec-native-dispatch.test.ts && tsx src/modules/mcp/ipsec-user-update-actions.test.ts && tsx src/modules/mcp/ipsec-server-delete-actions.test.ts && tsx src/services/ipsec-proof-telemetry.test.ts && tsx src/services/vrf-production-admission.test.ts && tsx src/modules/mcp/flow-host-keys-actions.test.ts && tsx src/services/vrf-production-read.test.ts && tsx --test src/services/vrf-production-wireguard-alias.test.ts && tsx src/modules/mcp/ipsec-user-create-actions.test.ts && tsx src/modules/mcp/flow-settings-actions.test.ts && tsx src/services/vrf-production-ticket-store.test.ts && npm run test:mcp:routing-baseline",
"test:wireguard": "tsx src/services/mikrotik-response-bounds.test.ts && tsx --test src/services/wireguard-dependency-read.test.ts && tsx src/services/wireguard-interface-update.test.ts && tsx src/services/wireguard-peer-update.test.ts && npx tsx src/services/wireguard-config.test.ts && npx tsx src/services/wireguard-ipv6.test.ts && npx tsx --test src/services/wireguard-import.test.ts src/services/wireguard-import-apply.test.ts src/services/wireguard-import-revision.test.ts src/services/wireguard-delete.test.ts && npx tsx src/routes/wireguard-import.test.ts && tsx src/services/wireguard-interface-create.test.ts && tsx src/services/wireguard-peer-create.test.ts && tsx src/services/wireguard-import-plan.test.ts && tsx src/services/wireguard-import-execution.test.ts && tsx src/modules/mcp/wireguard-import-actions.test.ts && tsx --test src/services/wireguard-export.test.ts",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-live.test.ts && tsx src/services/traffic-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts && tsx src/services/traffic-live-sql.test.ts && tsx src/services/traffic-volume-sql.test.ts && tsx src/services/traffic-binding-status.test.ts && tsx src/services/traffic-status-sql.test.ts && tsx src/services/traffic-gap.test.ts && tsx src/services/traffic-identity.test.ts && tsx src/services/traffic-membership.test.ts && tsx src/services/traffic-aggregate-peak.test.ts && tsx src/services/traffic-users-cache.test.ts && tsx src/services/traffic-collection-lock.test.ts && tsx src/services/traffic-concurrency-sql.test.ts",
"test:traffic-flow": "tsx --test src/services/traffic-flow-heartbeat.test.ts src/services/traffic-flow-heartbeat-wiring.test.ts src/services/traffic-flow-heartbeat-receiver.test.ts src/services/traffic-flow-ingest-quality.test.ts src/services/traffic-flow-quality-contract.test.ts && tsx src/services/traffic-flow-catalog-read.test.ts && tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-fact-accumulator.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts && tsx src/services/geoip-manual-update.test.ts && tsx src/services/traffic-flow-overlay-model.test.ts && npm run test:traffic-flow:ownership && tsx src/services/traffic-flow-worker-platform.test.ts && tsx src/services/traffic-flow-template-cache.test.ts && tsx --test src/services/traffic-flow-template-lifetime.test.ts && tsx src/services/traffic-flow-protocol-scope.test.ts && npm run test:traffic-flow:read-budget && npm run test:traffic-flow:resource-profile",
"test:users": "tsx ../packages/contracts/src/users.test.ts && tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
"test:users": "tsx ../packages/contracts/src/users.test.ts && tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/binding-target.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
"test:pg": "tsx src/db/pg-pool.test.ts && tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/db/scheduler-retention-index.test.ts && tsx src/services/config-revisions.test.ts",
"test:recursive-routes": "tsx src/services/recursive-route-identity.test.ts && tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts && tsx src/services/recursive-effective-properties.test.ts && tsx src/services/recursive-apply.test.ts",
"test:config-sync": "tsx src/services/owned-routing-policy.test.ts && tsx src/services/routing-filter-body.test.ts && tsx src/services/bgp-community-policy-model.test.ts && tsx src/services/bgp-connection-read.test.ts && tsx src/services/bgp-filter-binding-model.test.ts && tsx src/services/filter-rules-write-model.test.ts && tsx src/services/filter-rules-mutation.test.ts && tsx src/utils/server-id.test.ts && tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts && tsx src/services/gre-native-identity.test.ts && tsx src/services/gre-ipv6.test.ts && tsx src/routes/gre-ipv6-validation.test.ts && tsx src/services/network-disabled-server.test.ts",
@@ -168,7 +171,7 @@
"test:traffic-flow:read-budget": "tsx --test src/services/traffic-flow-read-budget.test.ts src/modules/mcp/flow-read-quality.test.ts && node scripts/test-flow-read-adapters.mjs",
"test:traffic-flow:read-http:pg": "node scripts/test-flow-read-http-owned.mjs",
"test:traffic-flow:read-source:pg": "tsx scripts/test-flow-read-sql-owned.mts",
"test:mcp:routing-baseline": "tsx --test src/modules/mcp/routing-controller-active-writer.test.ts src/modules/mcp/routing-controller-fence.test.ts src/modules/mcp/routing-community-public.test.ts src/modules/mcp/routing-controller-overlap.test.ts",
"test:mcp:routing-baseline": "tsx --test src/modules/mcp/routing-controller-active-writer.test.ts src/modules/mcp/routing-controller-baseline.test.ts src/modules/mcp/routing-controller-fence.test.ts src/modules/mcp/routing-community-public.test.ts src/modules/mcp/routing-controller-overlap.test.ts",
"test:mcp:routing-baseline:pg": "node scripts/test-routing-controller-baseline.mjs",
"test:mcp:routing-baseline-inverse:pg": "node scripts/test-routing-controller-baseline.mjs --default-scoped",
"test:sqlite-import-control:pg": "node scripts/test-sqlite-import-control-postgres.mjs",
@@ -20,7 +20,7 @@ test('fixed artifact is reproducible, resolves the compiled backend graph, and r
const packageJson=JSON.parse(await readFile(path.join(backend,'package.json'),'utf8'))
await mkdir(projectBackend,{recursive:true});await writeFile(path.join(projectBackend,'package.json'),JSON.stringify({type:'module',dependencies:packageJson.dependencies}))
const first=await buildDatabaseOperator({backendDirectory:backend,outputDirectory:output}),second=await buildDatabaseOperator({backendDirectory:backend,outputDirectory:output})
assert.deepEqual(first,second);assert.equal(first.modules.length,OPERATOR_SOURCES.length);assert.equal(first.modules.length,19);assert.deepEqual(first.runtimeDependencies,['dotenv','pg','zod']);assert.equal(first.backendDistModules.length,12)
assert.deepEqual(first,second);assert.equal(first.modules.length,OPERATOR_SOURCES.length);assert.equal(first.modules.length,20);assert.deepEqual(first.runtimeDependencies,['dotenv','pg','zod']);assert.equal(first.backendDistModules.length,12)
const copied=new Set()
async function copyDist(relative){
if(copied.has(relative))return;copied.add(relative)
@@ -36,10 +36,10 @@ test('fixed artifact is reproducible, resolves the compiled backend graph, and r
assert.deepEqual([...copied].sort(),first.backendDistModules.map(item=>item.file).sort())
// Exercise the real build entry point with its default backend/output paths.
const scripts=path.join(projectBackend,'scripts')
assert.equal(await stageDatabaseOperatorScripts(scripts,path.join(backend,'scripts')),21)
assert.equal(await stageDatabaseOperatorScripts(scripts,path.join(backend,'scripts')),OPERATOR_SOURCES.length+2)
const buildScript=path.join(scripts,'build-database-operator.mjs')
const built=spawnSync(process.execPath,[buildScript],{cwd:temporary,env:{...process.env,NODE_OPTIONS:''},encoding:'utf8',timeout:30000})
assert.equal(built.status,0,built.stderr);assert.equal(JSON.parse(built.stdout.trim()).modules,19)
assert.equal(built.status,0,built.stderr);assert.equal(JSON.parse(built.stdout.trim()).modules,20)
assert.deepEqual(JSON.parse(await readFile(path.join(output,'build-manifest.json'),'utf8')),first)
const configFile=path.join(projectBackend,'dist','config.js'),originalConfig=await readFile(configFile,'utf8')
for(const injected of ["import 'tsx';","import '../src/config.js';","import '../outside.js';"]){
@@ -61,9 +61,9 @@ test('fixed artifact is reproducible, resolves the compiled backend graph, and r
const modulesToImport=first.modules.map(item=>path.join(output,item.file))
const importer=path.join(temporary,'import-all.mjs');await writeFile(importer,`import {pathToFileURL} from 'node:url';for(const file of ${JSON.stringify(modulesToImport)})await import(pathToFileURL(file).href);console.log(JSON.stringify({imported:${modulesToImport.length},readyAuthorized:false}));`)
const imported=spawnSync(process.execPath,['--import',pathToFileURL(guard).href,importer],{cwd:temporary,env,encoding:'utf8',timeout:30000})
assert.equal(imported.status,0,imported.stderr);assert.equal(JSON.parse(imported.stdout.trim()).imported,19);assert.equal(JSON.parse(await readFile(telemetry,'utf8')).connections,0)
assert.equal(imported.status,0,imported.stderr);assert.equal(JSON.parse(imported.stdout.trim()).imported,20);assert.equal(JSON.parse(await readFile(telemetry,'utf8')).connections,0)
const refusals=[]
for(const [entry,event,args] of [['database-writer-bootstrap-operator.mjs','application-writer-bootstrap-refused',['invalid']],['database-writer-profile-operator.mjs','application-writer-profile-publication-refused',['invalid']],['database-writer-profile-operator.mjs','database-maintenance-command-refused',['maintenance-inspect']],['database-writer-profile-operator.mjs','database-maintenance-command-refused',['maintenance-resolve']]]){
for(const [entry,event,args] of [['database-writer-bootstrap-operator.mjs','application-writer-bootstrap-refused',['invalid']],['database-writer-profile-operator.mjs','application-writer-profile-publication-refused',['invalid']],['database-writer-profile-operator.mjs','database-maintenance-command-refused',['maintenance-inspect']],['database-writer-profile-operator.mjs','database-maintenance-command-refused',['maintenance-resolve']],['database-writer-profile-operator.mjs','database-source-activation-refused',['source-activate']]]){
const child=spawnSync(process.execPath,['--import',pathToFileURL(guard).href,path.join(output,entry),...args],{cwd:temporary,env,encoding:'utf8',timeout:30000})
assert.equal(child.status,1,child.stderr);assert.equal(child.stdout,'');assert.equal(JSON.parse(child.stderr.trim()).event,event);assert.equal(JSON.parse(await readFile(telemetry,'utf8')).connections,0);refusals.push(event)
}
@@ -2,7 +2,7 @@
export const OPERATOR_SOURCES=Object.freeze([
'database-writer-bootstrap-operator.mjs','database-writer-profile-operator.mts',
'writer-profile/operator-files.mjs','writer-profile/operator-contract.mjs','writer-profile/publisher.mjs','writer-profile/context.mjs',
'writer-profile/protected-source-registry.mts','writer-profile/maintenance-resolution.mts',
'writer-profile/protected-source-registry.mts','writer-profile/maintenance-resolution.mts','writer-profile/source-activation.mts',
'writer-profile/catalogue.mts','writer-profile/catalogue-value-bridge.ts','writer-profile/catalogue-projection.ts','writer-profile/catalogue-leaves.ts',
'writer-profile/application-writer-profile-catalogue.ts','writer-profile/application-writer-profile-catalogue-values.ts',
'writer-profile/application-writer-profile-catalogue-parent-policy.ts','writer-profile/application-writer-profile-canonical.ts',
@@ -8,10 +8,14 @@ import {approval,reconciliationApproval,poolOptions,roleKeys,assertJsDriverEnvir
import {deploymentRequest} from './database-writer-bootstrap-operator.mjs'
const hex=(v:unknown)=>typeof v==='string'&&/^[a-f0-9]{64}$/.test(v)
const parse=(b:Buffer)=>JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(b))
const sourceCommands=['source-activate','source-revoke','source-status']
export async function main(args:string[]){
if(sourceCommands.includes(args[0]))return (await import('./writer-profile/source-activation.mjs')).sourceActivationMain(args)
if(['maintenance-inspect','maintenance-resolve'].includes(args[0]))return (await import('./writer-profile/maintenance-resolution.mjs')).maintenanceMain(args)
const activePreflight=args[0]==='source-preflight',preflight=activePreflight||args[0]==='preflight'
const flags=['bootstrap','bootstrap-sha256','approval','approval-sha256','definition','definition-sha256','transport','transport-sha256','ca','source-registry','source-spki-sha256','journal']
assert(['publish','reconcile','preflight'].includes(args[0])&&args.length===1+2*flags.length)
if(activePreflight)flags.push('activation-directory','activation-sha256')
assert((preflight||['publish','reconcile'].includes(args[0]))&&args.length===1+2*flags.length)
assertJsDriverEnvironment(process.env)
const values=new Map<string,string>()
for(let i=1;i<args.length;i+=2){assert(args[i].startsWith('--')&&flags.includes(args[i].slice(2))&&!values.has(args[i].slice(2))&&args[i+1]);values.set(args[i].slice(2),args[i+1])}
@@ -32,34 +36,49 @@ export async function main(args:string[]){
// All explicit trust files are checked before app helper/PG imports. No .env URL is used.
// Existing source helper loads config/dotenv. Complete it BEFORE pg's first
// import so a newly loaded NODE_PG_FORCE_NATIVE cannot select another driver.
const {createDatabaseSourceBindingVerifier}=await import('../src/db/database-source-binding.js')
const {createProtectedDatabaseSourceBindingVerifier}=await import('./writer-profile/protected-source-registry.mjs')
assertJsDriverEnvironment(process.env)
const [{default:pg},{createConnectedPublisher},{collectSource44Catalogue,approvedDefinition}]=await Promise.all([
const [{default:pg},{createConnectedPublisher,requireCurrentCatalogueDefinition},{collectSource44Catalogue,approvedDefinition}]=await Promise.all([
import('pg'),import('./writer-profile/publisher.mjs'),import('./writer-profile/catalogue.mjs')])
assertJsDriverEnvironment(process.env)
assert.equal(approvedDefinition(definition),pinned.definitionSha256)
const installation=Object.freeze({...subject,roles:Object.fromEntries(roleKeys.map(k=>[k,request.roles[k].signedOid])),roleBindings:request.roles,expectedSource:request.source})
const reviewedDefinition=approvedDefinition(definition)
assert.equal(reviewedDefinition.definitionSha256,pinned.definitionSha256)
if(args[0]!=='reconcile')requireCurrentCatalogueDefinition(pinned,reviewedDefinition)
const installation=Object.freeze({...subject,roles:Object.fromEntries(roleKeys.map(k=>[k,request.roles[k].signedOid])),roleBindings:request.roles,expectedSource:request.source,tls:Object.freeze({...request.tls})})
const sourcePin={registryDirectory:get('source-registry'),installationId:subject.installationId,publicKeySpkiSha256:get('source-spki-sha256'),currentHeadSha256:request.sourceHeadSha256}
// Preflight is the read-only lifecycle entry for protected registry evidence.
// A refused protection check never falls back to the signature-only verifier.
const verifier=args[0]==='preflight'
?(await import('./writer-profile/protected-source-registry.mjs')).createProtectedDatabaseSourceBindingVerifier(sourcePin)
:createDatabaseSourceBindingVerifier(sourcePin)
const publisher=createConnectedPublisher(installation,(s:Parameters<typeof collectSource44Catalogue>[0])=>collectSource44Catalogue(s,installation),verifier,pinned)
const pool=new pg.Pool(options)
// A pool-level async error never becomes raw stdout or a retry signal.
let asyncFailure=false;pool.on('error',()=>{asyncFailure=true})
let result,primary:unknown,failed=false
try{result=args[0]==='preflight'?await publisher.preflightCurrentProfile(pool,{deadlineAt:Date.now()+120000}):args[0]==='reconcile'?await publisher.reconcilePendingPublication(pool,{journalDirectory:get('journal'),deadlineAt:Date.now()+120000}):await publisher.publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256:pinned.expectedHeadSha256,journalDirectory:get('journal'),validityMs:pinned.validityMs,deadlineAt:Date.now()+120000})}
catch(error){primary=error;failed=true}
try{await pool.end()}catch(error){primary??=error;failed=true}
if(args[0]==='preflight'&&result?.state==='matched'&&result.platformProtectionVerified===true&&!failed&&!asyncFailure)return {event:'application-writer-profile-preflight',outcome:'matched',epoch:result.epoch,headSha256:result.headSha256,notAfter:result.notAfter,sourceMatched:true,rolesMatched:true,catalogueMatched:true,platformProtectionVerified:true,writerAuthorized:false,cleanupUnconfirmed:false,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false}
if(result?.outcome==='committed')return {event:'application-writer-profile-publication',outcome:'committed',epoch:result.epoch,headSha256:result.headSha256,notAfter:result.notAfter,
cleanupUnconfirmed:result.cleanupUnconfirmed||asyncFailure||failed,journalUnconfirmed:!!result.journalUnconfirmed,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false}
if(args[0]==='reconcile'&&result&&!failed&&!asyncFailure&&['idle','confirmed','not-committed'].includes(result.state))return {event:'application-writer-profile-reconciliation',outcome:result.state,headSha256:result.headSha256??null,cleanupUnconfirmed:false,journalUnconfirmed:false,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false}
throw primary??Error('Publisher terminal unconfirmed')
// Use the same protected registry evidence for inspection and publication.
const verifier=createProtectedDatabaseSourceBindingVerifier(sourcePin)
async function connected(sourceVerifier:typeof verifier,deadlineAt:number){
const publisher=createConnectedPublisher(installation,(s:Parameters<typeof collectSource44Catalogue>[0])=>collectSource44Catalogue(s,installation),sourceVerifier,pinned,reviewedDefinition)
const pool=new pg.Pool(options)
// A pool-level async error never becomes raw stdout or a retry signal.
let asyncFailure=false;pool.on('error',()=>{asyncFailure=true})
let result,primary:unknown,failed=false
try{result=preflight?await publisher.preflightCurrentProfile(pool,{deadlineAt}):args[0]==='reconcile'?await publisher.reconcilePendingPublication(pool,{journalDirectory:get('journal'),deadlineAt}):await publisher.publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256:pinned.expectedHeadSha256,journalDirectory:get('journal'),validityMs:pinned.validityMs,deadlineAt})}
catch(error){primary=error;failed=true}
try{await pool.end()}catch(error){primary??=error;failed=true}
if(preflight&&result?.state==='matched'&&result.platformProtectionVerified===true&&!failed&&!asyncFailure)return {event:'application-writer-profile-preflight',outcome:'matched',epoch:result.epoch,headSha256:result.headSha256,notAfter:result.notAfter,sourceMatched:true,rolesMatched:true,catalogueMatched:true,platformProtectionVerified:true,writerAuthorized:false,cleanupUnconfirmed:false,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false}
if(result?.outcome==='committed')return {event:'application-writer-profile-publication',outcome:'committed',epoch:result.epoch,headSha256:result.headSha256,notAfter:result.notAfter,
cleanupUnconfirmed:result.cleanupUnconfirmed||asyncFailure||failed,journalUnconfirmed:!!result.journalUnconfirmed,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false}
if(args[0]==='reconcile'&&result&&!failed&&!asyncFailure&&['idle','confirmed','not-committed'].includes(result.state))return {event:'application-writer-profile-reconciliation',outcome:result.state,headSha256:result.headSha256??null,cleanupUnconfirmed:false,journalUnconfirmed:false,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false}
throw primary??Error('Publisher terminal unconfirmed')
}
const deadlineAt=Date.now()+120000
if(!activePreflight)return connected(verifier,deadlineAt)
const {withActiveSourcePreflight}=await import('./writer-profile/source-activation.mjs')
const publisherRole=request.roles.publisher.name
const expectedSource={...request.source,databaseOid:Number(request.source.databaseOid),sessionUser:publisherRole,currentUser:publisherRole}
const result=await withActiveSourcePreflight({directory:get('activation-directory'),expectedSha256:get('activation-sha256'),sourcePin,expectedSource,tlsSpkiSha256:request.tls.spkiSha256,deadlineAt},verifier,
guardedVerifier=>connected(guardedVerifier,deadlineAt))
assert.equal(result.event,'application-writer-profile-preflight')
return {...result,event:'application-writer-profile-source-preflight',sourceActivationMatched:true}
}
if(process.argv[1]&&import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href){
try{const result=await main(process.argv.slice(2));console.log(JSON.stringify(result));if(result.cleanupUnconfirmed||result.journalUnconfirmed)process.exitCode=2}
catch{console.error(JSON.stringify({event:['maintenance-inspect','maintenance-resolve'].includes(process.argv[2])?'database-maintenance-command-refused':'application-writer-profile-publication-refused',automaticReplay:false,inspectPendingBeforeRetry:true,readyAuthorized:false}));process.exitCode=1}
catch(error){
const sourceCommand=sourceCommands.includes(process.argv[2])
const unknown=sourceCommand&&(error as NodeJS.ErrnoException|null)?.code==='SOURCE_ACTIVATION_TERMINAL_UNKNOWN'
console.error(JSON.stringify({event:sourceCommand?(unknown?'database-source-activation-unknown':'database-source-activation-refused'):
['maintenance-inspect','maintenance-resolve'].includes(process.argv[2])?'database-maintenance-command-refused':process.argv[2]==='source-preflight'?'application-writer-profile-source-preflight-refused':'application-writer-profile-publication-refused',automaticReplay:false,inspectPendingBeforeRetry:true,readyAuthorized:false}));process.exitCode=unknown?2:1
}
}
@@ -6,22 +6,38 @@ import fs from 'node:fs/promises'
import {createRequire} from 'node:module'
import {runInNewContext} from 'node:vm'
import {EventEmitter} from 'node:events'
import {generateKeyPairSync} from 'node:crypto'
import path from 'node:path'
import * as format from '../src/db/application-writer-profile-format.ts'
import {MIGRATION_MANIFEST,migrationManifestDigest} from '../src/db/migration-manifest.ts'
import {readCurrentApplicationWriterProfile} from '../src/db/application-writer-profile-registry-read.ts'
import {syntheticFixture,stagedModelClient} from '../src/db/application-writer-profile-registry-read-model.test.ts'
import {approvedDefinition} from './writer-profile/catalogue.mts'
import {BUILD44_PROVENANCE,FIXED44_ROLE_TEMPLATE} from './writer-profile/fixed44-build-definition.ts'
const require=createRequire(new URL('../package.json',import.meta.url)),ts=require('typescript'),pg=require('pg')
const candidate=await fs.readFile(new URL('./writer-profile/publisher.mjs',import.meta.url),'utf8')
interface PreflightDiagnostics {state:string;headSha256:string;sourceMatched:boolean;rolesMatched:boolean;catalogueMatched:boolean;platformProtectionVerified:boolean;writerAuthorized:boolean;generationAuthenticated:boolean;readyAuthorized:boolean}
interface PublisherModule {createConnectedPublisher(installation:unknown,collect:()=>Promise<unknown>,sourceVerifier:{verify:()=>Promise<{platformProtectionVerified:boolean}>},approval:unknown):{preflightCurrentProfile(pool:unknown,options:{deadlineAt:number}):Promise<PreflightDiagnostics>}}
interface OperatorModule {main(args:string[]):Promise<{event:string;outcome:string;platformProtectionVerified:boolean;writerAuthorized:boolean;readyAuthorized:boolean}>}
interface PublisherModule {
requireCurrentCatalogueDefinition(approval:unknown,definition:unknown):string
createConnectedPublisher(installation:unknown,collect:()=>Promise<unknown>,sourceVerifier:{verify:()=>Promise<{platformProtectionVerified:boolean}>},approval:unknown,definition:unknown):{
preflightCurrentProfile(pool:unknown,options:{deadlineAt:number}):Promise<PreflightDiagnostics>
publishCurrentProfile(pool:unknown,options:{privateKeyFile:string;expectedHeadSha256:null;journalDirectory:string;deadlineAt:number}):Promise<{outcome:string}>
}
}
interface OperatorModule {main(args:string[]):Promise<{event:string;outcome:string;platformProtectionVerified:boolean;writerAuthorized:boolean;readyAuthorized:boolean;sourceActivationMatched?:boolean}>}
function compile<T>(source:string,modules:Record<string,unknown>):T{
const exports:Record<string,unknown>={};const compiled=ts.transpileModule(source.split('\nif(process.argv[1]')[0].replaceAll('import.meta.url',JSON.stringify('file:///candidate-not-cli.mts')),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText
runInNewContext(compiled,{exports,require:(name:string)=>{assert(name in modules,'Unapproved import '+name);return modules[name]},Buffer,URL,TextDecoder,Date,setTimeout,clearTimeout,Object,Error,AggregateError,Promise,Map,Set,process:{env:{},argv:[]}}, {filename:'candidate.mjs'})
// The isolated VM loads the exact source above; tests exercise this narrow export contract.
return exports as T
}
function fixture(fault=''){
const f=syntheticFixture('1'),catalogue={catalogSha256:'f'.repeat(64),definitionSha256:f.policy.definitionSha256,leafCount:117},security={sha256:'e'.repeat(64),roles:{...f.policy.roles}}
const frozenDefinition=approvedDefinition({format:'application-writer-source44-definition-approval-v1',provenance:BUILD44_PROVENANCE,template:FIXED44_ROLE_TEMPLATE})
const frozenMigrations=MIGRATION_MANIFEST.slice(0,44),markerIds=frozenMigrations.map(pin=>pin.file.slice(0,-4))
const markerSql='SELECT id FROM public.schema_migrations ORDER BY id COLLATE "C" LIMIT $1'
function fixture(fault='',forPublication=false){
const f=syntheticFixture('1'),catalogue={...frozenDefinition,catalogSha256:'f'.repeat(64),leafCount:117},security={sha256:'e'.repeat(64),roles:{...f.policy.roles}}
const currentBuild=fault==='current-build'||fault==='current-approved-frozen-definition'
f.policy.manifestSha256=frozenDefinition.manifestSha256;f.policy.definitionSha256=frozenDefinition.definitionSha256
f.policy.catalogSha256=format.sha(format.canonical({base:catalogue.catalogSha256,security:security.sha256}))
if(fault==='signed-source')f.policy.source.databaseOid='999'
if(fault==='signed-role')f.policy.roles.operator='99'
@@ -34,30 +50,49 @@ function fixture(fault=''){
f.state.current[0].head_sha256=top.head_sha256;f.state.policy=[{policy_sha256:hash,policy_bytes:raw}]
if(fault==='tamper')f.state.policy[0].policy_bytes=Buffer.from(raw.toString().replace('synthetic','tampered'))
if(fault==='empty'){f.state.current=[];f.state.latest=[];f.state.policy=[]}
const model=stagedModelClient(f.state),queries:string[]=[],events:string[]=[];let clocks=0
const model=stagedModelClient(f.state),queries:string[]=[],events:string[]=[],markerLimits:unknown[][]=[]
let clocks=0,checkouts=0
const client=Object.assign(new EventEmitter(),{query:async(sql:string,values?:unknown[])=>{
queries.push(sql)
if(sql.includes('clock_timestamp()')&&++clocks===2&&fault==='late-clock')return {rows:[{now:'1'}]}
if(sql===markerSql){
markerLimits.push(values??[])
const ids=fault==='marker-missing'?markerIds.slice(0,-1):fault==='marker-extra'||fault==='marker-drift'&&markerLimits.length===2?[...markerIds,'9999_foreign']:markerIds
return {rows:ids.map(id=>({id}))}
}
if(sql.startsWith('WITH')||sql.includes('clock_timestamp()'))return model.client.query(sql,values)
if(sql.startsWith('SELECT pg_catalog.pg_advisory_unlock'))return {rows:[{released:true}]}
if(sql==='ROLLBACK')return {rows:[],command:fault==='rollback-ack'?'':'ROLLBACK'}
if(sql.startsWith('INSERT INTO mmapp_control.current'))return {rows:[{id:1}]}
return {rows:[],command:sql.split(' ')[0]}
},release:(bad:boolean)=>{events.push('release:'+bad)}})
const pool=new pg.Pool();pool.connect=async()=>client
const pool=new pg.Pool();pool.connect=async()=>{checkouts++;return client}
const source={systemIdentifier:'123',databaseOid:'456',databaseName:'synthetic',serverVersionNum:180004,inRecovery:false}
let contexts=0,verifications=0
const installation={...f.subject,expectedSource:source,roleBindings:{publisher:{name:'publisher',signedOid:'14'}}}
const approval={expiresAt:Date.now()+60000,manifestSha256:'b'.repeat(64),definitionSha256:catalogue.definitionSha256,catalogSha256:format.sha(format.canonical({base:catalogue.catalogSha256,security:security.sha256}))}
const fixtureKey=forPublication?generateKeyPairSync('ed25519'):null
const subject=fixtureKey?{...f.subject,publicKeyPem:fixtureKey.publicKey.export({format:'pem',type:'spki'}).toString(),publicKeySpkiSha256:format.sha(fixtureKey.publicKey.export({format:'der',type:'spki'}))}:f.subject
const installation={...subject,expectedSource:source,roleBindings:{publisher:{name:'publisher',signedOid:'14'}}}
const approval={expiresAt:Date.now()+60000,manifestSha256:['approval-manifest','current-approved-frozen-definition'].includes(fault)?migrationManifestDigest():frozenDefinition.manifestSha256,definitionSha256:catalogue.definitionSha256,catalogSha256:format.sha(format.canonical({base:catalogue.catalogSha256,security:security.sha256}))}
const definition=fault==='static-definition'?{...frozenDefinition,definitionSha256:'0'.repeat(64)}:frozenDefinition
const privateKeyFile=path.resolve('modeled-fixture-key.pem'),journalDirectory=path.resolve('modeled-journal'),files=new Map<string,Buffer>()
if(fixtureKey)files.set(privateKeyFile,Buffer.from(fixtureKey.privateKey.export({format:'pem',type:'pkcs8'})))
function file(filename:string){const value=files.get(filename);if(!value)throw Object.assign(Error('Fixture file absent'),{code:'ENOENT'});return value}
function stat(filename:string){return {size:file(filename).length,dev:1,ino:1,isFile:()=>true,isSymbolicLink:()=>false}}
const modules={
pg,'node:crypto':require('node:crypto'),'node:fs/promises':{},'node:path':require('node:path'),
'./operator-files.mjs':{withJournalLease:()=>{throw Error('Preflight must not use publication journal')},writeFile:()=>{throw Error('No publication writes')}},
pg,'node:crypto':require('node:crypto'),'node:fs/promises':{realpath:async(file:string)=>file,lstat:async(filename:string)=>stat(filename),unlink:async(filename:string)=>files.delete(filename),open:async(filename:string)=>({stat:async()=>stat(filename),readFile:async()=>Buffer.from(file(filename)),close:async()=>{}})},'node:path':path,
'./operator-contract.mjs':{retainPublisherTlsPeer:(actual:unknown)=>{
assert.equal(actual,client)
if(fault==='initial-tls')throw Error('TLS peer refused')
return ()=>{if(fault==='lost-tls'&&verifications===2)throw Error('Retained TLS lost')}
}},
'./operator-files.mjs':{withJournalLease:async(_directory:string,work:()=>Promise<unknown>)=>{assert(forPublication);events.push('journal-lease');return work()},writeFile:async(filename:string,value:Buffer)=>{assert(forPublication);files.set(filename,value)}},
'../../src/db/database-source-observation.js':{captureDatabaseSourceObservation:async()=>{events.push('capture-source');return {}},assertDatabaseSourceObservationCurrent:async()=>{events.push('fresh-source');if(fault==='late-source')throw Error('Source changed')}},
'../../src/db/migration-manifest.js':{MIGRATION_MANIFEST:[],migrationManifestDigest:()=> 'b'.repeat(64)},
'../../src/db/migration-manifest.js':{MIGRATION_MANIFEST:currentBuild?MIGRATION_MANIFEST:frozenMigrations,migrationManifestDigest:()=>currentBuild?migrationManifestDigest():frozenDefinition.manifestSha256},
'../../src/db/application-writer-profile-format.js':format,
'./context.mjs':{ownedContext:async()=>{contexts++;if(fault==='late-actor'&&contexts===2)throw Error('Actor changed');return {pid:1,source}},sameOwned:(a:unknown,b:unknown)=>assert.deepEqual(a,b),sourceCoordinates:(v:{source:typeof source})=>v.source,protectedStructure:async()=>{events.push('roles');if(fault==='role-structure')throw Error('Unsafe role');return security},readCurrent:readCurrentApplicationWriterProfile,HISTORY:'history',CURRENT:'current'},
'./context.mjs':{ownedContext:async()=>{contexts++;if(fault==='late-actor'&&contexts===2)throw Error('Actor changed');return {pid:1,source}},sameOwned:(a:unknown,b:unknown)=>assert.deepEqual(a,b),sourceCoordinates:(v:{source:typeof source})=>v.source,protectedStructure:async()=>{events.push('roles');if(fault==='role-structure')throw Error('Unsafe role');return security},readCurrent:forPublication?async()=>null:readCurrentApplicationWriterProfile,HISTORY:'history',CURRENT:'current'},
}
const api=compile<PublisherModule>(candidate,modules).createConnectedPublisher(installation,async()=>{events.push('catalogue');if(fault==='catalogue-eof')throw Error('Incomplete EOF');return catalogue},{verify:async()=>{verifications++;events.push('verify-source');if(fault==='source-verifier')throw Error('Unsigned source');if(fault==='late-expiry'&&verifications===2)approval.expiresAt=1;return {platformProtectionVerified:!(fault==='initial-unprotected'&&verifications===1||fault==='terminal-unprotected'&&verifications===2)}}},approval)
return {run:()=>api.preflightCurrentProfile(pool,{deadlineAt:Date.now()+30000}),queries,events,f}
const api=compile<PublisherModule>(candidate,modules).createConnectedPublisher(installation,async()=>{events.push('catalogue');if(fault==='catalogue-eof')throw Error('Incomplete EOF');return fault==='catalogue-manifest'?{...catalogue,manifestSha256:migrationManifestDigest()}:fault==='catalogue-definition'?{...catalogue,definitionSha256:'0'.repeat(64)}:catalogue},{verify:async()=>{verifications++;events.push('verify-source');if(fault==='source-verifier')throw Error('Unsigned source');if(fault==='late-expiry'&&verifications===2)approval.expiresAt=1;return {platformProtectionVerified:!(fault==='initial-unprotected'&&verifications===1||fault==='terminal-unprotected'&&verifications===2)}}},approval,definition)
return {run:()=>api.preflightCurrentProfile(pool,{deadlineAt:Date.now()+30000}),publish:()=>api.publishCurrentProfile(pool,{privateKeyFile,journalDirectory,expectedHeadSha256:null,deadlineAt:Date.now()+30000}),queries,events,markerLimits,files,get checkouts(){return checkouts},f}
}
test('current44 preflight authenticates signed current and actual component inputs on one read-only lease',async()=>{
const f=fixture(),result=await f.run();assert.equal(result.state,'matched');assert.equal(result.headSha256,f.f.state.current[0].head_sha256)
@@ -65,9 +100,17 @@ test('current44 preflight authenticates signed current and actual component inpu
assert.equal(result.platformProtectionVerified,true)
assert.equal(result.writerAuthorized,false);assert.equal(result.generationAuthenticated,false);assert.equal(result.readyAuthorized,false)
assert.equal(f.queries.filter(q=>q.startsWith('BEGIN')).length,1);assert(f.queries.includes('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'));assert.equal(f.queries.filter(q=>q==='ROLLBACK').length,1)
assert.equal(format.canonical(f.markerLimits),'[[45]]')
assert(!f.queries.some(q=>/^(?:INSERT|UPDATE|DELETE|COMMIT|CREATE|ALTER|DROP)\b/.test(q)));assert.equal(f.events.at(-1),'release:false')
assert.deepEqual(f.events.slice(0,6),['capture-source','verify-source','roles','catalogue','verify-source','fresh-source'])
})
test('publisher refuses before SQL on a wrong peer and stops SQL when the retained TLS socket is lost',async()=>{
const initial=fixture('initial-tls')
await assert.rejects(initial.run());assert.deepEqual(initial.queries,[]);assert.deepEqual(initial.events,['release:true'])
const lost=fixture('lost-tls')
await assert.rejects(lost.run());assert.equal(lost.events.at(-1),'release:true')
assert(!lost.queries.some(q=>q==='ROLLBACK'||q.includes('pg_advisory_unlock')))
})
for(const fault of ['initial-unprotected','terminal-unprotected'])test('preflight cannot promote '+fault+' into protected evidence',async()=>{
const f=fixture(fault),result=await f.run();assert.equal(result.platformProtectionVerified,false)
assert.equal(result.writerAuthorized,false);assert.equal(result.readyAuthorized,false)
@@ -76,23 +119,64 @@ for(const fault of ['initial-unprotected','terminal-unprotected'])test('prefligh
for(const fault of ['tamper','empty','expired','signed-source','signed-role','signed-catalogue','signed-definition','signed-manifest','role-structure','catalogue-eof','source-verifier','late-source','late-actor','late-expiry','late-clock','rollback-ack'])test('preflight refuses '+fault+' without any write or replay',async()=>{
const f=fixture(fault);await assert.rejects(f.run());assert(!f.queries.some(q=>/^(?:INSERT|UPDATE|DELETE|COMMIT|CREATE|ALTER|DROP)\b/.test(q)));assert.equal(f.queries.filter(q=>q.startsWith('BEGIN')).length,1)
})
test('existing operator main dispatches preflight only after protected input validation, without private key or publication',async()=>{
for(const fault of ['current-build','current-approved-frozen-definition','approval-manifest','static-definition'])test('incompatible static manifest/definition refuses '+fault+' before either pool checkout or publication lease',async()=>{
const f=fixture(fault,true)
await assert.rejects(f.run(),/Approved catalogue definition is not bound/)
await assert.rejects(f.publish(),/Approved catalogue definition is not bound/)
assert.equal(f.checkouts,0);assert.deepEqual(f.events,[]);assert.deepEqual(f.queries,[])
})
for(const fault of ['catalogue-manifest','catalogue-definition','marker-missing','marker-extra'])test('retained preflight refuses '+fault+' and rolls back',async()=>{
const f=fixture(fault)
await assert.rejects(f.run(),error=>error instanceof Error&&/Catalogue evidence differs|Exact current migration marker/.test((error.cause as Error).message))
assert.equal(f.checkouts,1);assert.equal(f.queries.filter(q=>q==='ROLLBACK').length,1)
assert(!f.queries.some(q=>/^(?:INSERT|UPDATE|DELETE|COMMIT)\b/.test(q)))
if(fault.startsWith('marker-'))assert.equal(format.canonical(f.markerLimits),'[[45]]')
else assert.deepEqual(f.markerLimits,[])
})
test('publication checks exact marker set on both retained transactions and refuses late marker drift before registry writes',async()=>{
const positive=fixture('',true)
assert.equal((await positive.publish()).outcome,'committed')
assert.equal(positive.checkouts,1);assert.equal(format.canonical(positive.markerLimits),'[[45],[45]]')
assert.equal(positive.queries.filter(q=>q==='COMMIT').length,2)
const drift=fixture('marker-drift',true)
await assert.rejects(drift.publish(),error=>error instanceof Error&&/Exact current migration marker/.test((error.cause as Error).message))
assert.equal(drift.checkouts,1);assert.equal(format.canonical(drift.markerLimits),'[[45],[45]]')
assert.equal(drift.queries.filter(q=>q==='COMMIT').length,1);assert.equal(drift.queries.filter(q=>q==='ROLLBACK').length,1)
assert(!drift.queries.some(q=>/^(?:INSERT|UPDATE|DELETE)\b/.test(q)))
assert([...drift.files.keys()].some(filename=>filename.endsWith('pending-publication.json')))
})
test('operator requires the protected source verifier for preflight, publication and reconciliation',async()=>{
const cli=await fs.readFile(new URL('./database-writer-profile-operator.mts',import.meta.url),'utf8'),f=syntheticFixture('1'),reads:string[]=[],events:string[]=[]
const request={installationId:f.subject.installationId,roles:Object.fromEntries(['runtime','operator','capture','publisher','control'].map((k,i)=>[k,{name:k,signedOid:String(i+11)}])),source:f.policy.source,sourceHeadSha256:'a'.repeat(64),tls:{caSha256:'a'.repeat(64)}}
const files:Record<string,unknown>={bootstrap:{format:'application-writer-bootstrap-v1',publicationAuthorized:false,readyAuthorized:false,deploymentRequest:request,verificationSubject:f.subject},approval:{expiresAt:Date.now()+60000,definitionSha256:'b'.repeat(64)},definition:{},transport:{},ca:'fixture'}
let failEnd=false,asyncError=false,protectedRefused=false
const request={installationId:f.subject.installationId,roles:Object.fromEntries(['runtime','operator','capture','publisher','control'].map((k,i)=>[k,{name:k,signedOid:String(i+11)}])),source:f.policy.source,sourceHeadSha256:'a'.repeat(64),tls:{caSha256:'a'.repeat(64),spkiSha256:'d'.repeat(64)}}
const files:Record<string,unknown>={bootstrap:{format:'application-writer-bootstrap-v1',publicationAuthorized:false,readyAuthorized:false,deploymentRequest:request,verificationSubject:f.subject},approval:{expiresAt:Date.now()+60000,definitionSha256:'b'.repeat(64)},definition:{},transport:{},ca:'fixture','signing-private.pem':'fixture'}
let failEnd=false,asyncError=false,protectedRefused=false,activationRefused=false,incompatibleDefinition=false
const retainedPools:Pool[]=[]
class Pool extends EventEmitter{constructor(){super();retainedPools.push(this)}async end(){events.push('pool-end');if(failEnd)throw Error('Pool cleanup unconfirmed')}}
const result={state:'matched',epoch:'1',headSha256:'c'.repeat(64),notAfter:Date.now()+60000,platformProtectionVerified:true}
const protectedVerifier={verify:async()=>({platformProtectionVerified:true})}
const guardedVerifier={...protectedVerifier}
const modules={
'node:assert/strict':assert,'node:path':require('node:path'),'node:url':require('node:url'),'node:crypto':require('node:crypto'),
'./writer-profile/operator-files.mjs':{readProtected:async(file:string)=>{reads.push(file);assert(file in files);return Buffer.from(JSON.stringify(files[file]))},protectedDirectory:async()=>{},sha:format.sha},
'./writer-profile/operator-contract.mjs':{approval:(value:unknown)=>{events.push('approval');return value},reconciliationApproval:()=>{throw Error('No expired reconciliation bypass')},poolOptions:()=>{events.push('transport');return {}},roleKeys:['runtime','operator','capture','publisher','control'],assertJsDriverEnvironment:()=>{}},
'./database-writer-bootstrap-operator.mjs':{deploymentRequest:(value:unknown)=>value},'../src/db/database-source-binding.js':{createDatabaseSourceBindingVerifier:()=>{throw Error('Preflight must never fall back to unprotected source verification')}},
'./writer-profile/operator-contract.mjs':{approval:(value:unknown)=>{events.push('approval');return value},reconciliationApproval:(value:unknown)=>{events.push('reconciliation-approval');return value},poolOptions:()=>{events.push('transport');return {}},roleKeys:['runtime','operator','capture','publisher','control'],assertJsDriverEnvironment:()=>{}},
'./database-writer-bootstrap-operator.mjs':{deploymentRequest:(value:unknown)=>value},
'./writer-profile/protected-source-registry.mjs':{createProtectedDatabaseSourceBindingVerifier:(pin:{registryDirectory:string;currentHeadSha256:string})=>{events.push('protected-source');assert.equal(pin.registryDirectory,'source-registry');assert.equal(pin.currentHeadSha256,request.sourceHeadSha256);if(protectedRefused)throw Error('Protected source refused');return protectedVerifier}},
pg:{Pool},'./writer-profile/publisher.mjs':{createConnectedPublisher:(_installation:unknown,_collect:unknown,verifier:unknown)=>{assert.equal(verifier,protectedVerifier);return {preflightCurrentProfile:async()=>{events.push('preflight');if(asyncError){const activePool=retainedPools.at(-1);assert(activePool);activePool.emit('error',Error('Async pool fault'))}return result},publishCurrentProfile:()=>{throw Error('No publish')},reconcilePendingPublication:()=>{throw Error('No reconcile')}}}},
'./writer-profile/catalogue.mjs':{collectSource44Catalogue:()=>{},approvedDefinition:()=> 'b'.repeat(64)},
'./writer-profile/source-activation.mjs':{withActiveSourcePreflight:async(input:{directory:string;expectedSha256:string;sourcePin:{currentHeadSha256:string};expectedSource:unknown;tlsSpkiSha256:string;deadlineAt:number},verifier:unknown,work:(verifier:unknown)=>Promise<unknown>)=>{
assert.equal(verifier,protectedVerifier);assert.equal(input.directory,'active-registry');assert.equal(input.expectedSha256,'e'.repeat(64))
assert.equal(input.sourcePin.currentHeadSha256,request.sourceHeadSha256);assert.equal(input.tlsSpkiSha256,request.tls.spkiSha256);assert(input.deadlineAt>Date.now())
assert.equal(format.canonical(input.expectedSource),format.canonical({...request.source,databaseOid:Number(request.source.databaseOid),sessionUser:'publisher',currentUser:'publisher'}))
events.push('activation-admission');if(activationRefused)throw Error('Activation refused')
const value=await work(guardedVerifier);events.push('activation-terminal');return value
}},
pg:{Pool},'./writer-profile/publisher.mjs':{requireCurrentCatalogueDefinition:()=>{if(incompatibleDefinition)throw Error('Build manifest refused')},createConnectedPublisher:(_installation:unknown,_collect:unknown,verifier:unknown)=>{
assert.equal(verifier,events.includes('activation-admission')?guardedVerifier:protectedVerifier)
return {
preflightCurrentProfile:async()=>{events.push('preflight');if(asyncError){const activePool=retainedPools.at(-1);assert(activePool);activePool.emit('error',Error('Async pool fault'))}return result},
publishCurrentProfile:async()=>{events.push('publish');return {...result,outcome:'committed'}},
reconcilePendingPublication:async()=>{events.push('reconcile');return {state:'confirmed',headSha256:result.headSha256}},
}
}},
'./writer-profile/catalogue.mjs':{collectSource44Catalogue:()=>{},approvedDefinition:()=>({definitionSha256:'b'.repeat(64),manifestSha256:frozenDefinition.manifestSha256})},
}
const flags=['bootstrap','bootstrap-sha256','approval','approval-sha256','definition','definition-sha256','transport','transport-sha256','ca','source-registry','source-spki-sha256','journal'],args=['preflight',...flags.flatMap(flag=>['--'+flag,flag.endsWith('sha256')?'a'.repeat(64):flag])]
const output=await compile<OperatorModule>(cli,modules).main(args)
@@ -103,5 +187,37 @@ test('existing operator main dispatches preflight only after protected input val
asyncError=false;result.platformProtectionVerified=false;await assert.rejects(compile<OperatorModule>(cli,modules).main(args),/Publisher terminal unconfirmed/)
result.platformProtectionVerified=true;protectedRefused=true;const before=retainedPools.length
await assert.rejects(compile<OperatorModule>(cli,modules).main(args),/Protected source refused/);assert.equal(retainedPools.length,before,'Protection failure must not open a Pool or fall back')
for(const [command,approvalEvent,outcome] of [['publish','approval','committed'],['reconcile','reconciliation-approval','confirmed']]){
const commandArgs=[command,...args.slice(1)]
const poolCount=retainedPools.length
await assert.rejects(compile<OperatorModule>(cli,modules).main(commandArgs),/Protected source refused/)
assert.equal(retainedPools.length,poolCount)
protectedRefused=false
events.length=0;reads.length=0
const terminal=await compile<OperatorModule>(cli,modules).main(commandArgs)
assert.equal(terminal.outcome,outcome);assert.equal(terminal.readyAuthorized,false)
assert.deepEqual(events,[approvalEvent,'transport','protected-source',command,'pool-end'])
assert.equal(reads.includes('signing-private.pem'),command==='publish')
protectedRefused=true
}
const activeArgs=['source-preflight',...args.slice(1),'--activation-directory','active-registry','--activation-sha256','e'.repeat(64)]
protectedRefused=false;activationRefused=true;events.length=0;reads.length=0
const beforeActivation=retainedPools.length
await assert.rejects(compile<OperatorModule>(cli,modules).main(activeArgs),/Activation refused/)
assert.equal(retainedPools.length,beforeActivation)
assert.deepEqual(events,['approval','transport','protected-source','activation-admission'])
activationRefused=false;events.length=0;reads.length=0
const active=await compile<OperatorModule>(cli,modules).main(activeArgs)
assert.equal(active.event,'application-writer-profile-source-preflight');assert.equal(active.sourceActivationMatched,true)
assert.equal(active.readyAuthorized,false);assert.equal(active.writerAuthorized,false)
assert.deepEqual(events,['approval','transport','protected-source','activation-admission','preflight','pool-end','activation-terminal'])
assert.deepEqual(reads,['bootstrap','approval','definition','transport','ca'])
incompatibleDefinition=true;events.length=0;reads.length=0
const beforeBuildRefusal=retainedPools.length
for(const incompatibleArgs of [args,activeArgs,['publish',...args.slice(1)]])await assert.rejects(compile<OperatorModule>(cli,modules).main(incompatibleArgs),/Build manifest refused/)
assert.equal(retainedPools.length,beforeBuildRefusal)
assert(!events.some(event=>event==='protected-source'||event==='activation-admission'))
const historical=await compile<OperatorModule>(cli,modules).main(['reconcile',...args.slice(1)])
assert.equal(historical.outcome,'confirmed');assert.equal(historical.readyAuthorized,false)
})
@@ -0,0 +1,73 @@
/** External SDK consumer. No application/store/runtime/database imports. */
import assert from 'node:assert/strict'
import { randomUUID } from 'node:crypto'
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
const endpoint = new URL(process.env.OWN_MCP_ENDPOINT)
assert.equal(process.env.OWN_EXTERNAL_CLIENT, 'true')
assert.equal(endpoint.hostname, '127.0.0.1')
assert.notEqual(process.pid, Number(process.env.OWN_SERVER_PID))
const client = new Client({ name: 'owned-linked-bridge-vlan-sdk', version: '1.0.0' })
const boundedFetch = async (input, init) => {
const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url)
assert.equal(url.origin, endpoint.origin)
return fetch(input, { ...init, redirect: 'manual', signal: AbortSignal.timeout(10000) })
}
const payload = result => {
const text = result.content.find(item => item.type === 'text')?.text
assert(text && Buffer.byteLength(text) < 131072)
return JSON.parse(text)
}
const call = async (name, args) => {
const result = await client.callTool({ name, arguments: args }, undefined, { timeout: 10000 })
assert(!result.isError, JSON.stringify(payload(result)))
return payload(result)
}
try {
await client.connect(new StreamableHTTPClientTransport(endpoint, {
requestInit: { headers: { authorization: 'Bearer ' + process.env.OWN_MCP_TOKEN } }, fetch: boundedFetch,
}), { timeout: 10000 })
const tools = (await client.listTools({}, { timeout: 10000 })).tools
const read = tools.find(tool => tool.name === 'mm_bridge_vlan_configuration_get')
const configure = tools.find(tool => tool.name === 'mm_bridge_vlan_configuration_plan')
assert(read && configure)
assert.match(read.description, /Полная конфигурация bridge VLAN/)
assert.match(read.description, /Возвращает данные только из разрешённой области/)
assert.match(configure.description, /Связанный план VLAN membership, PVID и bridge filtering/)
assert.match(configure.description, /Подготавливает неизменяемый план/)
assert.equal(read.annotations.readOnlyHint, true)
assert.equal(configure.annotations.readOnlyHint, false)
assert.deepEqual(read.inputSchema.required, ['serverId'])
assert.deepEqual(configure.inputSchema.required, ['serverId', 'bridgeId', 'desired'])
const desiredSchema = configure.inputSchema.properties.desired
for (const name of ['ports', 'vlans', 'removePortIds', 'removeVlanIds']) assert(desiredSchema.properties[name])
const serverId = Number(process.env.OWN_ALLOWED_SERVER)
const state = (await call(read.name, { serverId })).state
assert.equal(state.bridges[0]['.id'], '*1')
assert(state.interfaces.some(item => item.resource === 'ethernet' && item.id === '*3' && item.name === 'ether3'))
assert.equal(state.vlans[0]['vlan-ids'], '10')
const cpu = { resource: 'bridge', id: '*1' }, access = { resource: 'ethernet', id: '*2' }, trunk = { resource: 'ethernet', id: '*3' }
const plan = await call(configure.name, { serverId, bridgeId: '*1', desired: {
filtering: true, pvid: 1,
ports: [{ id: '*10', interface: access, pvid: 10, ingressFiltering: true, frameTypes: 'admit-all', disabled: false },
{ interface: trunk, pvid: 1, ingressFiltering: true, frameTypes: 'admit-only-vlan-tagged', disabled: false }],
vlans: [{ id: '*11', vlanIds: '10', tagged: [cpu], untagged: [access], disabled: false },
{ vlanIds: '20', tagged: [cpu, trunk], untagged: [], disabled: false }], removePortIds: [], removeVlanIds: [],
} })
assert.equal(plan.state, 'awaiting-approval')
assert.equal(typeof plan.planId, 'string')
assert.equal(plan.changes.requiresHuman, true)
assert.deepEqual(plan.changes.steps.map(item => [item.step.resource, item.step.action]),
[['bridge_vlan', 'create'], ['bridge_port', 'create'], ['bridge', 'update']])
const denied = await client.callTool({ name: 'mm_change_apply', arguments: { planId: plan.planId, idempotencyKey: randomUUID() } }, undefined, { timeout: 10000 })
assert.equal(denied.isError, true)
const deniedPayload = payload(denied)
assert(JSON.stringify(deniedPayload).includes('APPROVAL_REQUIRED'))
const readback = (await call(read.name, { serverId })).state
assert.deepEqual(readback, state)
process.send({ kind: 'result', result: { externalSdkProcess: true, transport: 'streamable-http', clientPid: process.pid,
listedTools: [read, configure], state, plan, applyDenial: deniedPayload, unchangedReadback: true } })
} finally {
await client.close()
process.disconnect?.()
}
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import { createServer } from 'node:http'
import { writeFile } from 'node:fs/promises'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { isAbsolute, resolve } from 'node:path'
assert.equal(process.env.OWN_EXTERNAL_PROCESS_PG, 'true')
assert.equal(process.env.NODE_ENV, 'test')
assert(isAbsolute(process.env.OWN_EXTERNAL_SOURCE_ROOT!))
assert(isAbsolute(process.env.OWN_EXTERNAL_RESULT_FILE!))
const database = new URL(process.env.DATABASE_URL!)
assert.equal(database.hostname, '127.0.0.1'); assert.equal(database.username, 'mcp_test')
assert.equal(database.pathname, '/postgres'); assert.notEqual(database.port, '5432')
const source = (path: string) => pathToFileURL(resolve(process.env.OWN_EXTERNAL_SOURCE_ROOT!, 'backend/src', path)).href
const rows: Record<string, Record<string, string>[]> = {
'/interface/ethernet': [{ '.id': '*2', name: 'ether2', disabled: 'false' }, { '.id': '*3', name: 'ether3', disabled: 'false' }, { '.id': '*4', name: 'ether-mgmt', disabled: 'false' }],
'/interface/bridge': [{ '.id': '*1', name: 'br-users', pvid: '1', 'vlan-filtering': 'false', 'frame-types': 'admit-all', 'ingress-filtering': 'true', mvrp: 'false', disabled: 'false' }],
'/interface/bridge/port': [{ '.id': '*10', bridge: 'br-users', interface: 'ether2', pvid: '10', 'ingress-filtering': 'true', 'frame-types': 'admit-all', disabled: 'false' }],
'/interface/bridge/vlan': [{ '.id': '*11', bridge: 'br-users', 'vlan-ids': '10', tagged: 'br-users', untagged: 'ether2', disabled: 'false', dynamic: 'false' }],
'/interface/vlan': [], '/interface/gre': [], '/interface/wireguard': [], '/interface/vxlan': [],
'/ip/address': [{ '.id': '*20', interface: 'ether-mgmt', address: '127.0.0.1/8', disabled: 'false', dynamic: 'false' }], '/ipv6/address': [],
}
const requests: string[] = []
let writeAttempts = 0
const router = createServer((req, res) => {
res.setHeader('content-type', 'application/json')
const path = decodeURIComponent(new URL(req.url!, 'http://127.0.0.1').pathname.replace(/^\/rest/, ''))
requests.push(req.method + ' ' + path)
if (req.method !== 'GET') { writeAttempts++; res.writeHead(405); res.end('{}'); return }
if (req.headers.authorization !== 'Basic ' + Buffer.from('fixture:fixture-only').toString('base64')) { res.writeHead(401); res.end('{}'); return }
if (!Object.hasOwn(rows, path)) { res.writeHead(404); res.end('{}'); return }
res.end(JSON.stringify(rows[path]))
})
await new Promise<void>(done => router.listen(0, '127.0.0.1', done))
const { pool, closePool } = await import(source('db/index.js'))
const { applySqlMigrations } = await import(source('db/migrate.js'))
const { mcpConnectionCreateSchema } = await import('@mmapp/contracts/mcp')
const { createMcpConnection, updateMcpSettings } = await import(source('modules/mcp/store.js'))
const { buildApp } = await import(source('index.js'))
const actor = { id: 'owned-bridge-vlan-sdk-owner', email: 'bridge-sdk@example.invalid', name: 'Owned bridge VLAN SDK owner', apps: ['mm'], permissions: ['mm:settings:admin', 'mm:mcp:admin', 'mm:network:admin', 'mm:servers:admin'], expiresAt: Date.now() + 3600000, issuedAt: Date.now() - 1000 }
let app: Awaited<ReturnType<typeof buildApp>> | undefined
let child: ReturnType<typeof spawn> | undefined
let childExit: Promise<number> | undefined
try {
await applySqlMigrations(pool)
const serverId = (await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password) VALUES('owned bridge VLAN SDK','127.0.0.1',$1,false,true,'fixture','fixture-only') RETURNING id", [(router.address() as { port: number }).port])).rows[0].id
await updateMcpSettings({ enabled: true }, actor)
app = await buildApp({ startScheduler: false, logger: false })
await app.listen({ host: '127.0.0.1', port: 0 })
const endpoint = `http://127.0.0.1:${(app.server.address() as { port: number }).port}/mcp`
const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: 'Owned bridge VLAN external SDK', mode: 'autonomous',
expiresAt: new Date(Date.now() + 1200000).toISOString(), permissions: ['mm:network:write'],
scope: { serverIds: [serverId], operationIds: ['network.bridge-vlan.read', 'network.bridge-vlan.configure', 'changes.apply'], allowGlobal: false },
limits: { callsPerMinute: 120, mutationsPerHour: 100 } }), actor)
let result: unknown, stdout = '', stderr = ''
child = spawn(process.execPath, [fileURLToPath(new URL('./bridge-vlan-client.mjs', import.meta.url))], { cwd: process.cwd(), windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe', 'ipc'], env: { ...Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'PATH', 'Path'].filter(k => process.env[k]).map(k => [k, process.env[k]!])),
OWN_EXTERNAL_CLIENT: 'true', OWN_MCP_ENDPOINT: endpoint, OWN_MCP_TOKEN: issued.token, OWN_SERVER_PID: String(process.pid), OWN_ALLOWED_SERVER: String(serverId) } })
child.on('message', (message: { kind: string; result?: unknown }) => { if (message.kind === 'result') result = message.result })
child.stdout?.on('data', chunk => { stdout += String(chunk); if (stdout.length > 1048576) child?.kill() })
child.stderr?.on('data', chunk => { stderr += String(chunk); if (stderr.length > 1048576) child?.kill() })
childExit = new Promise<number>((done, reject) => { child!.once('exit', code => done(code ?? 1)); child!.once('error', reject) })
const timeout = setTimeout(() => child?.kill(), 60000)
const code = await childExit; clearTimeout(timeout)
assert.equal(code, 0, stderr.slice(0, 3000)); assert(result)
assert.equal(writeAttempts, 0)
assert.equal((await pool.query('SELECT count(*)::int AS count FROM mcp_operations')).rows[0].count, 0)
const plans = (await pool.query('SELECT operation_id,state,approved_by FROM mcp_plans')).rows
assert.equal(plans.length, 1); assert.equal(plans[0].operation_id, 'network.bridge-vlan.configure')
assert.equal(plans[0].state, 'awaiting-approval'); assert.equal(plans[0].approved_by, null)
const audit = (await pool.query('SELECT tool,status,error_code FROM mcp_requests ORDER BY created_at')).rows
assert(audit.some(row => row.error_code === 'APPROVAL_REQUIRED'))
const serialized = JSON.stringify({ result, stdout, stderr, audit })
assert(!serialized.includes(issued.token)); assert(!serialized.includes('fixture-only'))
const final = { ...(result as object), actualOwnPg: true, actualProductionHttpServer: true, serverFixturePid: process.pid,
connectionMode: 'autonomous', plans, audit, nativeReadRequests: requests, nativeWriteAttempts: writeAttempts,
appliedOperations: 0, syntheticRouterOnly: true, productionDatabaseTouched: false, portalContacted: false }
await writeFile(process.env.OWN_EXTERNAL_RESULT_FILE!, JSON.stringify(final, null, 2))
console.log('Owned bridge VLAN SDK passed:', JSON.stringify({ listedTools: 2, planState: plans[0].state, nativeReadRequests: requests.length, nativeWriteAttempts: writeAttempts }))
} finally {
if (child && child.exitCode === null && child.signalCode === null) { child.kill(); await childExit }
try { await app?.close() } finally { try { await closePool() } finally { router.closeIdleConnections(); await new Promise<void>((done, reject) => router.close(error => error ? reject(error) : done())) } }
}
+151 -5
View File
@@ -6,6 +6,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'
const endpoint = new URL(process.env.OWN_MCP_ENDPOINT), origin = endpoint.origin
assert.equal(endpoint.hostname, '127.0.0.1'); assert.equal(process.env.OWN_EXTERNAL_CLIENT, 'true')
let phase = 'start', next = 0, maxResponseBytes = 0, stderrBytes = 0, bridgePid = null
const responseLimitBytes = 1024 * 1024
const pending = new Map(), cases = []
const activeClients = new Set()
let closing = false
@@ -22,7 +23,7 @@ async function boundedFetch(input, init) {
if (response.body) {
const reader = response.body.getReader()
try {
for (;;) { const item = await reader.read(); if (item.done) break; count += item.value.length; if (count >= 1048576) { await reader.cancel(); throw Error('Own external client response bound') }; chunks.push(item.value) }
for (;;) { const item = await reader.read(); if (item.done) break; count += item.value.length; if (count >= responseLimitBytes) { await reader.cancel(); throw Error('Own external client response bound') }; chunks.push(item.value) }
} finally { reader.releaseLock() }
}
const bytes = Buffer.concat(chunks); maxResponseBytes = Math.max(maxResponseBytes, bytes.length)
@@ -36,14 +37,14 @@ function content(result) {
return parsed
}
async function denied(work) { try { const r = await work(); assert.equal(r?.isError, true) } catch (e) { if (e?.code === 'ERR_ASSERTION') throw e; assert(!/timeout|timed out/i.test(String(e?.message)), 'A timeout is not authorization denial') } }
async function opened(mode, token) {
async function opened(mode, token, maxBufferSize = 131072) {
const client = new Client({ name: 'actual-external-process-' + mode, version: '1.0.0' })
activeClients.add(client)
let transport
if (mode === 'http') transport = new StreamableHTTPClientTransport(endpoint, { requestInit: { headers: { authorization: 'Bearer ' + token } }, fetch: boundedFetch })
else {
transport = new StdioClientTransport({ command: process.execPath, args: ['--import', import.meta.resolve('tsx'), process.env.OWN_BRIDGE_FILE],
cwd: process.cwd(), env: { MCP_URL: endpoint.href, MCP_TOKEN: token }, stderr: 'pipe', maxBufferSize: 131072 })
cwd: process.cwd(), env: { MCP_URL: endpoint.href, MCP_TOKEN: token }, stderr: 'pipe', maxBufferSize })
transport.stderr?.on('data', chunk => { stderrBytes += chunk.length; const text = String(chunk); assert(!text.includes(token)); assert(stderrBytes < 1048576) })
const start = transport.start.bind(transport)
transport.start = async () => { await start(); bridgePid = transport.pid; assert(bridgePid && bridgePid !== process.pid && bridgePid !== Number(process.env.OWN_SERVER_PID)); process.send({ kind: 'bridge-pid', pid: bridgePid }) }
@@ -68,6 +69,36 @@ try {
phase = mode + ':initialize'; let client = await opened(mode, token)
try {
await scoped(client); cases.push(mode + ': separate SDK process initializes, filters tool/host scope, bounds page/result and redacts credentials')
phase = mode + ':policy-edit'
const editScope = async operationIds => {
const response = await boundedFetch(origin + '/api/mcp/connections/' + initial.connectionId, {
method: 'PUT', headers: { authorization: 'Bearer ' + process.env.OWN_PORTAL_JWT, 'content-type': 'application/json' },
body: JSON.stringify({ name: 'Owned external SDK process', mode: 'observer', expiresAt: new Date(Date.now() + 600000).toISOString(),
permissions: ['mm:servers:read'], scope: { serverIds: [Number(process.env.OWN_ALLOWED_SERVER)], operationIds, allowGlobal: false }, limits: { callsPerMinute: 200 } }),
})
assert.equal(response.status, 200)
}
await editScope(['logs.query'])
assert.deepEqual((await client.listTools({}, { timeout: 5000 })).tools.map(tool => tool.name), ['mm_logs_query'])
const allowedServer = Number(process.env.OWN_ALLOWED_SERVER), forbiddenServer = Number(process.env.OWN_FORBIDDEN_SERVER)
const queryLogs = arguments_ => client.callTool({ name: 'mm_logs_query', arguments: arguments_ }, undefined, { timeout: 5000 })
const filters = { q: 'external-sdk-log-', limit: 1 }
const first = content(await queryLogs(filters))
assert.ok(first.nextCursor)
const second = content(await queryLogs({ ...filters, cursor: first.nextCursor }))
assert.equal(second.nextCursor, null)
assert.deepEqual([first.entries, second.entries].map(entries => entries.map(({ serverId, message }) => ({ serverId, message }))), [
[{ serverId: allowedServer, message: 'external-sdk-log-1' }],
[{ serverId: allowedServer, message: 'external-sdk-log-2' }],
])
await denied(() => queryLogs({ ...filters, serverId: forbiddenServer }))
cases.push(mode + ': populated log search pages return only allowed server records and reject the forbidden server')
await denied(() => client.callTool({ name: 'mm_servers_list', arguments: {} }, { timeout: 5000 }))
await editScope([])
assert.deepEqual((await client.listTools({}, { timeout: 5000 })).tools, [])
await editScope(['servers.list'])
await scoped(client)
cases.push(mode + ': established client observes removed and restored tool scope through the web policy API')
if (mode === 'stdio' && process.env.OWN_EXTERNAL_CLEANUP_FAULT === 'failure') throw Error('Owned deliberate external client failure with bridge active')
if (mode === 'stdio' && process.env.OWN_EXTERNAL_CLEANUP_FAULT === 'timeout') { process.send({ kind: 'arm-cleanup-timeout' }); await new Promise(() => {}) }
phase = mode + ':result-bound'; await control('large-result')
@@ -86,7 +117,7 @@ try {
} finally { await client.close().catch(() => {}) }
}
phase = 'http:body-bound'; const bound = await control('new-connection')
const oversized = await fetch(endpoint, { method: 'POST', headers: { authorization: 'Bearer ' + bound.token, 'content-type': 'application/json', accept: 'application/json,text/event-stream' },
const oversized = await fetch(endpoint, { method: 'POST', headers: { authorization: 'Bearer ' + bound.token, 'content-type': 'application/json', accept: 'application/json,text/event-stream', connection: 'close' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'mm_servers_list', arguments: { padding: 'x'.repeat(1048576) } } }), signal: AbortSignal.timeout(5000) })
assert.equal(oversized.status, 413); assert((await oversized.text()).length < 65536)
cases.push('actual HTTP request envelope over1MiB refused before tool execution; successful client results remain bounded')
@@ -126,8 +157,123 @@ try {
await denied(() => oauthClient.callTool({ name: 'mm_servers_list', arguments: {} }, undefined, { timeout: 5000 }))
cases.push('actual OAuth family revocation refuses established and fresh external transport; no implicit new consent granted')
} finally { await oauthClient.close().catch(() => {}) }
phase = 'discovery:issue'
const catalogue = await control('discovery-connection')
const expectedNames = catalogue.names.toSorted()
assert(expectedNames.includes('mm_logs_follow'))
const discoveryByTransport = {}
for (const mode of ['http', 'stdio']) {
phase = `discovery:${mode}`
const client = await opened(mode, catalogue.token, responseLimitBytes)
try {
const names = [], cursors = new Set()
let cursor, largestPageBytes = 0, pages = 0
do {
const page = await client.listTools(cursor ? { cursor } : {}, { timeout: 10000 })
assert(page.tools.length > 0)
for (const tool of page.tools) {
assert.equal(tool.inputSchema.type, 'object')
names.push(tool.name)
}
const encoded = JSON.stringify(page)
assert(!encoded.includes(catalogue.token) && !encoded.includes(process.env.OWN_SERVER_SECRET))
largestPageBytes = Math.max(largestPageBytes, Buffer.byteLength(encoded))
assert(largestPageBytes < responseLimitBytes)
pages++
cursor = page.nextCursor
if (cursor) { assert(!cursors.has(cursor)); cursors.add(cursor) }
assert(names.length <= expectedNames.length)
} while (cursor)
assert.deepEqual(names.toSorted(), expectedNames)
discoveryByTransport[mode] = { tools: names.length, pages, largestPageBytes }
cases.push(mode + ': external SDK discovers every delegated tool exactly once with an object input schema')
} finally { await client.close().catch(() => {}) }
}
phase = 'discovery:revoke'
await control('revoke')
for (const [mode, arm] of [['http', 'arm-log-revocation'], ['stdio', 'arm-log-revocation-after-commit'], ['http', 'arm-log-follow-revocation']]) {
phase = mode + ':' + arm
const { token } = await control(arm)
const logClient = await opened(mode, token)
try {
const follow = arm === 'arm-log-follow-revocation'
const tool = follow ? 'mm_logs_follow' : 'mm_logs_query'
const filters = { q: follow ? 'external-sdk-live-event' : 'external-sdk-log-1', limit: 1 }
if (follow) {
Object.assign(filters, { serverId: Number(process.env.OWN_ALLOWED_SERVER), waitSeconds: 0 })
const start = content(await logClient.callTool({ name: tool, arguments: filters }, undefined, { timeout: 5000 }))
assert.deepEqual(start.entries, [])
assert.equal(typeof start.cursor, 'string')
filters.cursor = start.cursor
filters.waitSeconds = 10
}
const query = logClient.callTool({ name: tool, arguments: filters }, undefined, { timeout: 10000 })
const checked = query.then(result => {
assert.equal(result.isError, true)
const wire = JSON.stringify(result)
assert.match(wire, /OWNER_CLAIMS_REVOKED|UNAUTHORIZED/)
assert.doesNotMatch(wire, /external-sdk-log-1|external-sdk-live-event|"entries"|"nextCursor"|"cursor"/)
})
const deliver = async () => {
if (follow) await control('publish-follow-fixture-row')
await control('deliver-log-revocation')
}
await Promise.all([checked, deliver()])
await assert.rejects(opened(mode, token))
cases.push(mode + ': signed portal revocation ' + (mode === 'stdio' ? 'after' : 'before') + ' audit commit denies ' + tool + ' result and reconnect')
} finally { await logClient.close().catch(() => {}) }
}
phase = 'portal:revoke-export-download'
const exportAccess = await control('new-artifact-connection')
const exportClient = await opened('http', exportAccess.token)
try {
const exported = content(await exportClient.callTool({ name: 'mm_logs_export', arguments: {
serverId: Number(process.env.OWN_ALLOWED_SERVER), q: 'external-sdk-log-1', format: 'json',
} }, undefined, { timeout: 5000 }))
assert.equal(exported.rows, 1)
assert.equal(exported.downloadPath, '/api/mcp/artifacts/' + exported.artifactId)
assert.doesNotMatch(JSON.stringify(exported), /external-sdk-log-1/)
const download = jwt => boundedFetch(origin + exported.downloadPath, { headers: { authorization: 'Bearer ' + jwt } })
const before = await download(exportAccess.ownerJwt)
assert.equal(before.status, 200)
assert.equal(before.headers.get('cache-control'), 'private, no-store')
const bytes = Buffer.from(await before.arrayBuffer())
assert.equal(createHash('sha256').update(bytes).digest('hex'), exported.sha256)
assert.match(bytes.toString('utf8'), /external-sdk-log-1/)
const fresh = await control('revoke-artifact-owner')
for (const jwt of [exportAccess.ownerJwt, fresh.ownerJwt]) {
const refused = await download(jwt)
assert.ok([401, 403].includes(refused.status))
assert.equal(refused.headers.has('content-disposition'), false)
assert.doesNotMatch(await refused.text(), /external-sdk-log-1/)
}
const management = await boundedFetch(origin + '/api/mcp/connections', { headers: { authorization: 'Bearer ' + fresh.ownerJwt } })
assert.equal(management.status, 200)
cases.push('portal owner revocation blocks an existing unexpired log artifact even with refreshed owner login')
} finally { await exportClient.close().catch(() => {}) }
phase = 'portal:revoke-approved-plan'
const planAccess = await control('new-revocable-plan-connection')
const planClient = await opened('http', planAccess.token)
try {
const plan = content(await planClient.callTool({ name: 'mm_logs_settings_update_plan', arguments: {
settings: { enabled: false },
} }, undefined, { timeout: 5000 }))
assert.equal(plan.state, 'awaiting-approval')
const approved = await boundedFetch(origin + '/api/mcp/plans/' + plan.planId + '/decision', {
method: 'POST', headers: { authorization: 'Bearer ' + planAccess.ownerJwt, 'content-type': 'application/json' },
body: JSON.stringify({ approve: true }),
})
assert.equal(approved.status, 200)
assert.deepEqual(await approved.json(), { ok: true })
await control('revoke-approved-plan-owner')
await denied(() => planClient.callTool({ name: 'mm_change_apply', arguments: {
planId: plan.planId, idempotencyKey: randomUUID(),
} }, undefined, { timeout: 5000 }))
cases.push('signed portal revocation invalidates the exact approved log settings plan before apply')
} finally { await planClient.close().catch(() => {}) }
process.send({ kind: 'result', result: { cases: cases.length, names: cases, externalClientPid: process.pid, serverPid: Number(process.env.OWN_SERVER_PID), bridgePid,
sdkOnlyExternalClient: true, maxObservedHttpResponseBytes: maxResponseBytes, clientResponseLimitBytes: 1048576, stdioSdkBufferLimitBytes: 131072,
sdkOnlyExternalClient: true, maxObservedHttpResponseBytes: maxResponseBytes, clientResponseLimitBytes: responseLimitBytes, stdioSdkBufferLimitBytes: 131072,
discoveryByTransport, discoveryStdioSdkBufferLimitBytes: responseLimitBytes,
actualPublicStringLimitObserved: 16000, wholeGenericResultEnvelopeLimitAccepted: false,
syntheticPortalJwtAndConsent: true, liveAuthPortal: false, livePortalSender: false, endUserUi: false, wholeExternalDirectionComplete: false } })
} catch (error) { process.send?.({ kind: 'failure', phase, errorType: error?.name ?? 'Error', completedCases: cases }); process.exitCode = 1 }
@@ -6,10 +6,16 @@ import { randomBytes, createHash } from 'node:crypto'
import { createServer } from 'node:net'
import { spawn } from 'node:child_process'
const sourceRoot = resolve(process.argv[2] ?? fileURLToPath(new URL('../../../', import.meta.url)))
const output = resolve(sourceRoot, 'tmp/mcp-external-process-acceptance'); await mkdir(output, { recursive: true })
const scenario = process.argv[3] ?? 'acceptance'
if (!['acceptance', 'bridge-vlan'].includes(scenario)) throw Error('Unsupported owned external scenario')
const output = resolve(sourceRoot, scenario === 'bridge-vlan' ? 'tmp/mcp-bridge-vlan-sdk-20261105' : 'tmp/mcp-external-process-acceptance'); await mkdir(output, { recursive: true })
const paths = ['backend/src/scripts/mcp-stdio.ts', 'backend/src/routes/mcp-transport.ts', 'backend/src/routes/mcp-oauth.ts', 'backend/src/modules/mcp/store.ts',
'backend/src/modules/mcp/runtime.ts', 'backend/src/modules/mcp/actions.ts', 'backend/src/modules/mcp/security.ts', 'backend/src/modules/mcp/protocol-limits.ts',
'backend/src/modules/mcp/portal-revocation.ts', 'backend/src/plugins/auth.ts', 'backend/src/index.ts', 'node_modules/@modelcontextprotocol/client/package.json', 'node_modules/@modelcontextprotocol/server/package.json']
if (scenario === 'acceptance') paths.push('backend/scripts/mcp-external-process/client.mjs', 'backend/scripts/mcp-external-process/server-fixture.ts',
'backend/src/routes/mcp-portal.ts', 'backend/src/modules/mcp/portal-revocation-event.ts', 'backend/src/services/router-logs-query.ts')
else paths.push('backend/scripts/mcp-external-process/bridge-vlan-client.mjs', 'backend/scripts/mcp-external-process/bridge-vlan-fixture.ts',
'backend/src/modules/mcp/bridge-vlan-actions.ts', 'backend/src/services/bridge-vlan-configuration.ts', 'packages/contracts/src/bridge-vlan.ts')
async function pins() { const result = []; for (const path of paths) result.push({ path, sha256: createHash('sha256').update(await readFile(resolve(sourceRoot, path))).digest('hex') }); return result }
const before = await pins(), directory = await mkdtemp(join(resolve(sourceRoot, 'tmp'), 'mm-mcp-external-')), state = join(directory, 'state'); await mkdir(state)
const listener = createServer(); await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve)); const port = listener.address().port; await new Promise(resolve => listener.close(resolve))
@@ -19,7 +25,7 @@ const postgres = new EmbeddedPostgres({ databaseDir: join(directory, 'pg'), port
try {
await postgres.initialise(); await postgres.start()
process.exitCode = await new Promise((done, reject) => {
const child = spawn(process.execPath, ['--import', import.meta.resolve('tsx'), fileURLToPath(new URL('./server-fixture.ts', import.meta.url))], { cwd: directory,
const child = spawn(process.execPath, ['--import', import.meta.resolve('tsx'), fileURLToPath(new URL(scenario === 'bridge-vlan' ? './bridge-vlan-fixture.ts' : './server-fixture.ts', import.meta.url))], { cwd: directory,
windowsHide: true, stdio: 'inherit', env: { ...Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'PATH', 'Path'].filter(k => process.env[k]).map(k => [k, process.env[k]])),
OWN_EXTERNAL_CLEANUP_FAULT: process.env.OWN_EXTERNAL_CLEANUP_FAULT, OWN_EXTERNAL_SOURCE_ROOT: sourceRoot, OWN_EXTERNAL_RESULT_FILE: join(output, 'result.json'), NODE_ENV: 'test', OWN_EXTERNAL_PROCESS_PG: 'true', DATABASE_URL: `postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres`, DATABASE_PATH: join(directory, 'no-legacy.sqlite'),
MCP_STATE_DIR: state, AUTH_REQUIRED: 'true', AUTH_JWT_SECRET: 'owned-external-synthetic-issuer-test-only', AUTH_ISSUER: 'https://synthetic-portal.invalid', AUTH_PORTAL_URL: 'https://synthetic-portal.invalid' } })
@@ -1,4 +1,5 @@
import assert from "node:assert/strict"
import { createHash, createHmac, randomUUID } from "node:crypto"
import { spawn } from "node:child_process"
import { createServer } from "node:net"
import { fileURLToPath, pathToFileURL } from "node:url"
@@ -19,6 +20,7 @@ const { pool, closePool } = await import(source("db/index.js"))
const { applySqlMigrations } = await import(source("db/migrate.js"))
const { mcpConnectionCreateSchema } = await import("@mmapp/contracts/mcp")
const { createMcpConnection, rotateConnectionToken, setConnectionStatus, updateMcpSettings } = await import(source("modules/mcp/store.js"))
const { listActions } = await import(source("modules/mcp/actions.js")) as typeof import("../../src/modules/mcp/actions.js")
const { env } = await import(source("config.js"))
const { buildApp } = await import(source("index.js"))
const actor = { id: "owned-external-process-owner", email: "external@example.invalid", name: "Owned external SDK fixture", apps: ["mm"],
@@ -27,7 +29,27 @@ let currentId: string | undefined, app: Awaited<ReturnType<typeof buildApp>> | u
const credentials: string[] = []
const bridgePids = new Set<number>()
let childExit: Promise<number> | undefined
let releaseLogRead: (() => void) | undefined
let logReadCompleted: Promise<void> | undefined
let revocationOwnerId: string | undefined
const revokedLogRequests: { connectionId: string; tool: string }[] = []
const originalConnect = pool.connect
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
async function deliverPortalRevocation(ownerId: string) {
const secret = randomUUID() + randomUUID()
process.env.MCP_PORTAL_WEBHOOK_SECRET = secret
const occurredAt = Date.now(), timestamp = String(Math.floor(occurredAt / 1000))
const eventId = randomUUID(), body = JSON.stringify({ version: 2, eventId, userId: ownerId, type: "permissions.changed", eventOccurredAt: occurredAt })
const signature = "sha256=" + createHmac("sha256", secret).update(timestamp + "." + body).digest("hex")
const response = await fetch(origin + "/_internal/mcp/portal/revocations", {
method: "POST", headers: { "content-type": "application/json", "x-mm-timestamp": timestamp, "x-mm-signature": signature },
body, signal: AbortSignal.timeout(5000),
})
assert.equal(response.status, 200)
assert.deepEqual(await response.json(), { ok: true, duplicate: false })
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_portal_events WHERE id=$1", [eventId])).rows[0].count, 1)
return occurredAt
}
function alive(pid: number) { try { process.kill(pid, 0); return true } catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; throw error } }
async function drainChildAndBridges() {
if (child && child.exitCode === null && child.signalCode === null) { child.kill(); await Promise.race([childExit, delay(5000).then(() => { throw Error("Owned external child failed to exit") })]) }
@@ -46,9 +68,73 @@ try {
const secret = "OWNED_DEVICE_CREDENTIAL_MUST_NOT_ESCAPE"
const allowed = (await pool.query("INSERT INTO servers(name,host,enabled,password) VALUES('owned allowed','192.0.2.1',false,$1) RETURNING id", [secret])).rows[0].id
const forbidden = (await pool.query("INSERT INTO servers(name,host,enabled) VALUES('owned forbidden','192.0.2.2',false) RETURNING id")).rows[0].id
await pool.query(`INSERT INTO router_log_entries(server_id,server_name,host,router_id,raw_time,received_at,effective_at,topics,level,message,generation)
SELECT id,name,host,'fixture','00:00:01',now(),now()-n*interval '1 second',ARRAY['system'],'info','external-sdk-log-'||n,0
FROM servers CROSS JOIN generate_series(1,2) AS n WHERE id=ANY($1::int[])`, [[allowed, forbidden]])
await updateMcpSettings({ enabled: true }, actor)
app = await buildApp({ startScheduler: false, logger: false }); await app.listen({ port, host: "127.0.0.1" })
const jwt = await new SignJWT({ sub: actor.id, apps: actor.apps, permissions: actor.permissions }).setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setIssuedAt().setExpirationTime("1h").sign(new TextEncoder().encode(env.jwtSecret))
const artifactOwner = { ...actor, id: actor.id + "-artifact" }
const signArtifactOwner = () => new SignJWT({ sub: artifactOwner.id, apps: artifactOwner.apps, permissions: artifactOwner.permissions })
.setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setIssuedAt().setExpirationTime("1h").sign(new TextEncoder().encode(env.jwtSecret))
let artifactConnectionId: string | undefined
const planOwner = { ...actor, id: actor.id + "-approved-plan", permissions: [...actor.permissions, "mm:mcp:approve"] }
let planConnectionId: string | undefined
const portalHeaders = { authorization: `Bearer ${jwt}`, "content-type": "application/json" }
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_connections WHERE owner_id=$1", [actor.id])).rows[0].count, 0)
const callback = "http://localhost:45678/callback?registered=1"
const registration = await fetch(origin + "/api/mcp/oauth/clients", { method: "POST", headers: portalHeaders,
body: JSON.stringify({ name: "Owned denial fixture", redirectUris: [callback] }) })
assert.equal(registration.status, 200)
const { clientId } = await registration.json() as { clientId: string }
const consent = (payload: Record<string, unknown>, headers = portalHeaders) => fetch(origin + "/api/mcp/oauth/consent", { method: "POST", headers, body: JSON.stringify(payload) })
const authorize = async (state: string) => {
const parameters = new URLSearchParams({ client_id: clientId, redirect_uri: callback, response_type: "code", resource: endpoint,
code_challenge_method: "S256", code_challenge: createHash("sha256").update("v".repeat(64)).digest("base64url"), state })
const response = await fetch(`${origin}/oauth/authorize?${parameters}`, { redirect: "manual" })
assert.equal(response.status, 302)
const requestId = new URL(response.headers.get("location")!).searchParams.get("oauthRequest")
assert.ok(requestId)
return requestId
}
const requestId = await authorize("deny-without-connection")
assert.equal((await consent({ requestId, approve: false }, { ...portalHeaders, authorization: "" })).status, 401)
assert.equal((await consent({ requestId, approve: true })).status, 400)
const refusals = await Promise.all([consent({ requestId, approve: false }), consent({ requestId, approve: false })])
assert.deepEqual(refusals.map(response => response.status).sort(), [200, 404])
const { redirectUrl } = await refusals.find(response => response.status === 200)!.json() as { redirectUrl: string }
const redirect = new URL(redirectUrl)
const expected = new URL(callback)
expected.searchParams.set("state", "deny-without-connection")
expected.searchParams.set("iss", origin)
expected.searchParams.set("error", "access_denied")
assert.equal(redirect.href, expected.href)
const otherOwner = { ...actor, id: "owned-external-process-other-owner" }
const unrelated = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Unrelated paused connection", mode: "observer",
expiresAt: new Date(Date.now() + 600000).toISOString(), permissions: ["mm:servers:read"],
scope: { serverIds: [Number(allowed)], operationIds: ["servers.list"], allowGlobal: false } }), otherOwner)
await setConnectionStatus(unrelated.connection.id, "paused", otherOwner)
const legacyRequestId = await authorize("legacy-denial")
const legacy = await consent({ requestId: legacyRequestId, connectionId: unrelated.connection.id, approve: false })
assert.equal(legacy.status, 200)
assert.equal((await consent({ requestId: legacyRequestId, connectionId: unrelated.connection.id, approve: false })).status, 404)
const expired = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Expired connection", mode: "observer",
expiresAt: new Date(Date.now() + 600000).toISOString(), permissions: ["mm:servers:read"],
scope: { serverIds: [Number(allowed)], operationIds: ["servers.list"], allowGlobal: false } }), actor)
await pool.query("UPDATE mcp_connections SET policy=jsonb_set(policy,'{expiresAt}',to_jsonb($2::text)) WHERE id=$1", [expired.connection.id, "2000-01-01T00:00:00Z"])
const expiredRequestId = await authorize("expired-approval")
assert.equal((await consent({ requestId: expiredRequestId, connectionId: expired.connection.id, approve: true })).status, 403)
const expiredRequest = (await pool.query("SELECT consumed_at,code_digest,connection_id FROM mcp_oauth_requests WHERE id=$1", [expiredRequestId])).rows[0]
assert.deepEqual(expiredRequest, { consumed_at: null, code_digest: null, connection_id: null })
assert.equal((await consent({ requestId: expiredRequestId, approve: false })).status, 200)
const deniedRows = (await pool.query("SELECT consumed_at,code_digest,connection_id FROM mcp_oauth_requests WHERE client_id=$1", [clientId])).rows
assert.equal(deniedRows.length, 3)
assert(deniedRows.every(row => row.consumed_at instanceof Date && row.code_digest === null && row.connection_id === null))
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_credentials WHERE client_id=$1", [clientId])).rows[0].count, 0)
const denialAudit = (await pool.query("SELECT actor_id,connection_id,payload FROM mcp_audit WHERE action='oauth.consent' AND payload->>'clientId'=$1", [clientId])).rows
assert.equal(denialAudit.length, 3)
assert(denialAudit.every(row => row.actor_id === actor.id && row.connection_id === null && row.payload.approved === false))
console.log("OAuth consent: expired approval refused before code issuance, denial uses exact redirect and one-use request without credentials")
const transcript: unknown[] = []; let failure: unknown, result: unknown
child = spawn(process.execPath, [fileURLToPath(new URL("./client.mjs", import.meta.url))], { cwd: process.cwd(), windowsHide: true, stdio: ["ignore", "pipe", "pipe", "ipc"],
env: { ...Object.fromEntries(["SystemRoot", "WINDIR", "TEMP", "TMP", "PATH", "Path"].filter(k => process.env[k]).map(k => [k, process.env[k]!])),
@@ -67,10 +153,131 @@ try {
if (message.kind !== "control" || !Number.isSafeInteger(message.id)) return
try {
let value: unknown
if (message.command === "new-connection") {
if (message.command === "new-revocable-plan-connection") {
assert(!planConnectionId)
const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({
name: "Owned approved plan revocation", mode: "approval", permissions: ["mm:settings:admin"],
expiresAt: new Date(Date.now() + 600000).toISOString(),
scope: { serverIds: [Number(allowed), Number(forbidden)], operationIds: ["logs.settings.update", "changes.apply"], allowGlobal: true },
}), planOwner)
currentId = planConnectionId = issued.connection.id
const ownerJwt = await new SignJWT({ sub: planOwner.id, apps: planOwner.apps, permissions: planOwner.permissions })
.setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setIssuedAt().setExpirationTime("1h").sign(new TextEncoder().encode(env.jwtSecret))
credentials.push(issued.token, ownerJwt)
value = { token: issued.token, ownerJwt }
} else if (message.command === "revoke-approved-plan-owner") {
assert(planConnectionId && currentId === planConnectionId)
const plans = (await pool.query("SELECT id,state,approved_by FROM mcp_plans WHERE connection_id=$1", [planConnectionId])).rows
assert.equal(plans.length, 1)
assert.equal(plans[0].state, "approved")
assert.equal(plans[0].approved_by, planOwner.id)
await deliverPortalRevocation(planOwner.id)
value = { ok: true }
} else if (message.command === "new-artifact-connection") {
assert(!artifactConnectionId)
const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({
name: "Owned export revocation", mode: "observer", permissions: ["mm:servers:read"],
expiresAt: new Date(Date.now() + 600000).toISOString(),
scope: { serverIds: [Number(allowed)], operationIds: ["logs.export"], allowGlobal: false },
}), artifactOwner)
currentId = artifactConnectionId = issued.connection.id
const ownerJwt = await signArtifactOwner()
credentials.push(issued.token, ownerJwt)
value = { token: issued.token, ownerJwt }
} else if (message.command === "revoke-artifact-owner") {
assert(artifactConnectionId && currentId === artifactConnectionId)
const occurredAt = await deliverPortalRevocation(artifactOwner.id)
while (Math.floor(Date.now() / 1000) <= Math.floor(occurredAt / 1000)) await delay(25)
const ownerJwt = await signArtifactOwner()
credentials.push(ownerJwt)
value = { ownerJwt }
} else if (["arm-log-revocation", "arm-log-revocation-after-commit", "arm-log-follow-revocation"].includes(message.command ?? "")) {
assert(!releaseLogRead)
const afterCommit = message.command === "arm-log-revocation-after-commit"
const follow = message.command === "arm-log-follow-revocation"
const owner = { ...actor, id: `${actor.id}-${follow ? "follow" : afterCommit ? "postcommit" : "precommit"}` }
revocationOwnerId = owner.id
if (follow) {
await pool.query("UPDATE servers SET enabled=true WHERE id=$1", [allowed])
await pool.query("UPDATE router_log_settings SET enabled=true WHERE id=1")
}
const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({
name: "Owned portal revocation during log read", mode: "observer", permissions: ["mm:servers:read"],
expiresAt: new Date(Date.now() + 600000).toISOString(),
scope: { serverIds: [Number(allowed)], operationIds: [follow ? "logs.follow" : "logs.query"], allowGlobal: false },
}), owner)
currentId = issued.connection.id
revokedLogRequests.push({ connectionId: currentId, tool: follow ? "mm_logs_follow" : "mm_logs_query" })
credentials.push(issued.token)
let completed!: () => void
logReadCompleted = new Promise<void>(resolve => { completed = resolve })
const hold = new Promise<void>(resolve => { releaseLogRead = resolve })
pool.connect = ((callback?: unknown) => {
if (typeof callback === "function") return Reflect.apply(originalConnect, pool, [callback])
return (async () => {
const client = await originalConnect.call(pool)
const query = client.query, release = client.release
let completingLog = false
client.query = (async (...args: unknown[]) => {
const params = args[1]
if (typeof args[0] === "string" && args[0].startsWith("UPDATE mcp_requests SET status=$2,result=")
&& Array.isArray(params) && params[1] === "completed" && String(params[2]).includes(follow ? "external-sdk-live-event" : "external-sdk-log-1")) {
completingLog = true
if (!afterCommit) { completed(); await hold }
}
const result = await Reflect.apply(query, client, args)
if (afterCommit && completingLog && args[0] === "COMMIT") { completed(); await hold }
return result
}) as typeof query
client.release = error => {
client.query = query
client.release = release
release(error)
}
return client
})()
}) as typeof originalConnect
value = { token: issued.token }
} else if (message.command === "publish-follow-fixture-row") {
assert(releaseLogRead && currentId && revokedLogRequests.at(-1)?.tool === "mm_logs_follow")
const deadline = Date.now() + 3000
for (;;) {
const live = await pool.query("SELECT count(*)::int AS count FROM mcp_read_leases WHERE connection_id=$1", [currentId])
if (live.rows[0].count === 1) break
assert(Date.now() < deadline, "Owned follow did not acquire a live read lease")
await delay(25)
}
await pool.query(`INSERT INTO router_log_entries(server_id,server_name,host,router_id,raw_time,received_at,effective_at,topics,level,message,generation)
SELECT id,name,host,'fixture','00:00:01',now(),now(),ARRAY['system'],'info','external-sdk-live-event',0
FROM servers WHERE id=$1`, [allowed])
value = { ok: true }
} else if (message.command === "deliver-log-revocation") {
assert(logReadCompleted && releaseLogRead && currentId && revocationOwnerId)
try {
await Promise.race([logReadCompleted, delay(5000).then(() => { throw Error("Owned log read did not reach the barrier") })])
await deliverPortalRevocation(revocationOwnerId)
value = { ok: true }
} finally {
pool.connect = originalConnect
releaseLogRead()
releaseLogRead = undefined
logReadCompleted = undefined
revocationOwnerId = undefined
}
} else if (message.command === "new-connection") {
const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Owned external SDK process", mode: "observer", expiresAt: new Date(Date.now() + 1200000).toISOString(),
permissions: ["mm:servers:read"], scope: { serverIds: [Number(allowed)], operationIds: ["servers.list"], allowGlobal: false }, limits: { callsPerMinute: 200 } }), actor)
currentId = issued.connection.id; credentials.push(issued.token); value = { token: issued.token, connectionId: currentId }
} else if (message.command === "discovery-connection") {
const operations = listActions().map(action => action.operation)
const permissions = [...new Set(operations.flatMap(operation => operation.requiredPermissions))]
const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({
name: "Owned full catalogue discovery", mode: "approval", expiresAt: new Date(Date.now() + 600000).toISOString(),
permissions, scope: { serverIds: [Number(allowed)], operationIds: operations.map(operation => operation.id), allowGlobal: true },
}), { ...actor, permissions: [...new Set([...actor.permissions, ...permissions])] })
currentId = issued.connection.id
credentials.push(issued.token)
value = { token: issued.token, names: operations.map(operation => operation.toolName) }
} else if (message.command === "large-result" || message.command === "reset-result") {
await pool.query("UPDATE servers SET comment=$2 WHERE id=$1", [allowed, message.command === "large-result" ? "Z".repeat(40000) + "OWNED_TRUNCATION_TAIL" : ""])
value = { ok: true }
@@ -88,11 +295,30 @@ try {
const code = await childExit; clearTimeout(timer)
if (code !== 0 || !result) { console.log("OWNED_EXTERNAL_PROCESS_FAILURE", JSON.stringify({ code, failure, stdout: stdout.slice(0,1000), stderr: stderr.slice(0,1000) })); throw Error("External SDK process did not complete") }
const logs = JSON.stringify((await pool.query("SELECT tool,input,result,error_code FROM mcp_requests ORDER BY created_at")).rows)
assert.equal(revokedLogRequests.length, 3)
assert.ok(planConnectionId)
const invalidated = (await pool.query("SELECT state FROM mcp_plans WHERE connection_id=$1", [planConnectionId])).rows
assert.deepEqual(invalidated, [{ state: "invalidated" }])
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_operations WHERE connection_id=$1", [planConnectionId])).rows[0].count, 0)
assert.equal((await pool.query("SELECT enabled FROM router_log_settings WHERE id=1")).rows[0].enabled, true)
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_artifacts WHERE connection_id=$1 AND expires_at>now()", [artifactConnectionId])).rows[0].count, 1)
for (const { connectionId, tool } of revokedLogRequests) {
const revokedRead = (await pool.query("SELECT status,error_code,result FROM mcp_requests WHERE connection_id=$1 AND tool=$2 AND status='error'", [connectionId, tool])).rows
assert.equal(revokedRead.length, 1)
assert.equal(revokedRead[0].status, "error")
assert.deepEqual(Object.keys(revokedRead[0].result), ["message"])
assert.doesNotMatch(JSON.stringify(revokedRead[0].result), /external-sdk-log-1|external-sdk-live-event|"entries"|"nextCursor"|"cursor"/)
assert.ok(["OWNER_CLAIMS_REVOKED", "UNAUTHORIZED"].includes(revokedRead[0].error_code))
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_read_leases WHERE connection_id=$1", [connectionId])).rows[0].count, 0)
}
for (const token of [...credentials, jwt, secret]) { assert(!logs.includes(token)); assert(!stdout.includes(token)); assert(!stderr.includes(token)) }
const final = { ...(result as object), actualOwnPg: true, actualProductionHttpServer: true, serverFixturePid: process.pid,
controlTransitions: transcript, auditCredentialLeakAbsent: true, nativeRouterCalls: 0, productionDatabaseTouched: false }
controlTransitions: transcript, auditCredentialLeakAbsent: true, denialWithoutConnection: true,
legacyDenialIgnoresPausedForeignConnection: true, nativeRouterCalls: 0, productionDatabaseTouched: false }
await writeFile(process.env.OWN_EXTERNAL_RESULT_FILE!, JSON.stringify(final, null, 2))
console.log("Owned external process SDK result:", JSON.stringify(final))
} finally {
pool.connect = originalConnect
releaseLogRead?.()
try { await drainChildAndBridges() } finally { try { if (app) await app.close() } finally { await closePool() } }
}
@@ -0,0 +1,105 @@
import assert from 'node:assert/strict'
import {execFileSync} from 'node:child_process'
import {randomBytes, X509Certificate} from 'node:crypto'
import {mkdtemp, mkdir, cp, readFile, rm, chmod, access} from 'node:fs/promises'
import {createServer} from 'node:net'
import {tmpdir} from 'node:os'
import {dirname, join, resolve} from 'node:path'
import EmbeddedPostgres from 'embedded-postgres'
import pg from 'pg'
import {poolOptions, retainPublisherTlsPeer} from './writer-profile/operator-contract.mjs'
import {sha} from './writer-profile/operator-files.mjs'
const openssl = process.env.MMAPP_TLS_FIXTURE_OPENSSL || 'openssl'
const host = 'localhost'
const reservePort = async () => {
const listener = createServer()
await new Promise((ready, reject) => listener.once('error', reject).listen(0, '127.0.0.1', ready))
return listener
}
const releasePort = listener => new Promise((ready, reject) => listener.close(error => error ? reject(error) : ready()))
const sourcePort = await reservePort()
const clonePort = await reservePort()
const port = sourcePort.address().port, clonePortNumber = clonePort.address().port
await Promise.all([releasePort(sourcePort), releasePort(clonePort)])
const transport = {host, port, user: 'mcp_test', password: randomBytes(24).toString('hex'), database: 'postgres'}
const certFile = 'server.pem', keyFile = 'server-key.pem'
const root = await mkdtemp(join(tmpdir(), 'mm-writer-tls-owned-'))
const databaseDir = join(root, 'pg'), cloneDir = join(root, 'pg-clone')
const sourceTlsDir = join(root, 'source-tls'), cloneTlsDir = join(root, 'clone-tls')
const cluster = (databaseDir, port, tlsDir) => new EmbeddedPostgres({databaseDir, port, user: transport.user, password: transport.password,
persistent: true, createPostgresUser: false, postgresFlags: ['-h', '127.0.0.1', '-c', 'ssl=on', '-c', `ssl_cert_file=${join(tlsDir, certFile)}`, '-c', `ssl_key_file=${join(tlsDir, keyFile)}`],
onLog: () => {}, onError: () => {}})
const postgres = cluster(databaseDir, port, sourceTlsDir), clone = cluster(cloneDir, clonePortNumber, cloneTlsDir)
let sourceStarted = false, cloneStarted = false
const fingerprint = ca => sha(new X509Certificate(ca).publicKey.export({format:'der', type:'spki'}))
const issue = async tlsDir => {
await mkdir(tlsDir, {mode:0o700})
execFileSync(openssl, ['req', '-new', '-newkey', 'rsa:2048', '-x509', '-nodes', '-days', '1', '-subj', '/CN=localhost',
'-addext', 'subjectAltName=DNS:localhost', '-keyout', keyFile, '-out', certFile], {cwd: tlsDir, stdio: 'ignore', windowsHide: true})
await chmod(join(tlsDir, keyFile), 0o600)
return readFile(join(tlsDir, certFile))
}
const withClient = async (config, work) => {
const pool = new pg.Pool({connectionTimeoutMillis:5000, ...config})
let client
try { client = await pool.connect(); return await work(client) }
finally { client?.release(true); await pool.end() }
}
const query = "SELECT (pg_control_system()).system_identifier::text AS system, oid::text AS oid FROM pg_database WHERE datname=current_database()"
try {
await postgres.initialise()
const first = await issue(sourceTlsDir)
await postgres.start(); sourceStarted = true
const pin = {serverName:host, caSha256:sha(first), spkiSha256:fingerprint(first)}
const request = {tls:pin, roles:{publisher:{name:transport.user}}, source:{databaseName:transport.database}}
const baseline = await withClient(poolOptions(transport, request, first), async client => {
const retained = retainPublisherTlsPeer(client, pin)
retained()
const {rows:[source]} = await client.query(query)
const {rows:[status]} = await client.query('SELECT ssl FROM pg_stat_ssl WHERE pid=pg_backend_pid()')
assert.equal(status.ssl, true)
await client.query("CREATE TABLE owned_tls_fixture (note text PRIMARY KEY); INSERT INTO owned_tls_fixture VALUES ('original')")
const connection = client.connection, socket = connection.stream
connection.stream = undefined
try { assert.throws(retained, /retained TLS peer unavailable/) } finally { connection.stream = socket }
return source
})
await withClient({...transport, ssl:false}, async client => assert.throws(() => retainPublisherTlsPeer(client,pin), /retained TLS peer unavailable/))
await postgres.stop(); sourceStarted = false
await cp(databaseDir, cloneDir, {recursive:true, force:false, errorOnExist:true})
await assert.rejects(access(join(cloneDir, keyFile)), {code:'ENOENT'})
const second = await issue(cloneTlsDir)
assert.notEqual(fingerprint(second), pin.spkiSha256)
await postgres.start(); sourceStarted = true
await clone.start(); cloneStarted = true
const cloneTransport = {...transport, port:clonePortNumber}
const bothTrusted = Buffer.concat([first,second])
await assert.rejects(withClient(poolOptions(cloneTransport, {...request,tls:{...pin,caSha256:sha(bothTrusted)}}, bothTrusted),
() => assert.fail('Wrong physical cluster must not reach application SQL')), /TLS SPKI refused/)
await withClient({...cloneTransport, ssl:{ca:bothTrusted, servername:host, rejectUnauthorized:true}}, async client => {
assert.equal(client.connection.stream.authorized, true)
const {rows:[source]} = await client.query(query)
assert.deepEqual(source, baseline)
assert.throws(() => retainPublisherTlsPeer(client,pin), /TLS SPKI refused/)
retainPublisherTlsPeer(client,{...pin,spkiSha256:fingerprint(second)})()
assert.deepEqual((await client.query('SELECT note FROM owned_tls_fixture')).rows, [{note:'original'}])
await client.query("INSERT INTO owned_tls_fixture VALUES ('clone-only')")
})
await withClient(poolOptions(transport, request, first), async client => {
retainPublisherTlsPeer(client,pin)()
assert.deepEqual((await client.query('SELECT note FROM owned_tls_fixture')).rows, [{note:'original'}])
})
console.log('Genuine PostgreSQL TLS: original accepted; independently writable physical clone with identical DB coordinates and a different CA-valid TLS key refused')
} finally {
const stops = await Promise.allSettled([
cloneStarted ? clone.stop() : undefined,
sourceStarted ? postgres.stop() : undefined,
])
assert(stops.every(result => result.status === 'fulfilled'), 'Owned cluster stop unconfirmed')
assert.equal(dirname(resolve(root)), resolve(tmpdir()))
assert.match(root, /mm-writer-tls-owned-/)
await rm(root, {recursive:true, force:true, maxRetries:20, retryDelay:100})
await assert.rejects(access(root), {code:'ENOENT'})
console.log('Owned PostgreSQL and temporary TLS material removed')
}
+6 -2
View File
@@ -165,6 +165,10 @@ const chrVpn = process.argv.includes('--chr-vpn-catalog')
const chrFilters = process.argv.includes('--chr-filter-write')
const chrLegacyOspf = process.argv.includes('--chr-ospf-legacy')
const greRevision = process.argv.includes('--gre-revision')
const usersBindings = process.argv.includes('--users-bindings')
if (usersBindings && (process.argv.length !== 3 || process.argv[2] !== '--users-bindings')) throw new Error('User bindings acceptance requires its own disposable cluster')
const bridgeVlanConfiguration = process.argv.includes('--bridge-vlan-configuration')
if (bridgeVlanConfiguration && process.argv.slice(2).some(arg => arg !== '--bridge-vlan-configuration')) throw new Error('Bridge VLAN acceptance requires its own disposable cluster')
const applicationAdapters = process.argv.includes('--application-adapters')
const scopedRoutingDiagnostics = process.argv.includes('--scoped-routing-diagnostics')
const interfaceEventsCompatibility = process.argv.includes('--interface-events-compatibility')
@@ -246,7 +250,7 @@ try {
if (regressionOnly) console.log('Selected regression only: primary integration and terminal restore are not run')
if (profilesOnly) console.log('Profiles-only regression matrix: primary integration NOT RUN; terminal restore NOT RUN; full acceptance is not claimed')
const result = regressionOnly || profilesOnly ? 0 : await new Promise((resolve, reject) => {
const child = spawn(process.execPath, pollMemory ? ['--max-old-space-size=192', '--max-semi-space-size=16', fileURLToPath(new URL('../dist-mcp-memory/services/server-poll-memory.owned.test.js', import.meta.url))] : ['--import', 'tsx', principalSecuritySource ? 'src/modules/mcp/principal-security-source.owned.test.ts' : pollJournalSnapshot ? 'src/services/server-poll-journal-snapshot.owned.test.ts' : pollTerminalArchive ? 'src/services/server-poll-terminal-archive.owned.test.ts' : pollArchiveSchema ? 'src/db/server-poll-terminal-archives.sql.test.ts' : telemetrySettingsConfiguration ? 'src/modules/mcp/telemetry-settings-configuration.owned.test.ts' : routingReservationSchema ? 'src/db/routing-reservation-schema.owned.test.ts' : routingForwardRounds ? 'src/modules/mcp/routing-network-forward-rounds.owned.test.ts' : flowActiveServices ? 'src/services/traffic-flow-active-services.owned.test.ts' : flowActiveRebuild ? 'src/services/traffic-flow-active-rebuild.owned.test.ts' : flowQuality ? 'src/services/traffic-flow-quality.owned.test.ts' : flowWorkerIpc ? 'src/services/traffic-flow-worker-ipc.owned.test.ts' : manualUptime ? 'src/modules/mcp/manual-uptime-http.test.ts' : manualPingGroup ? 'src/modules/mcp/manual-ping-group-http.test.ts' : manualTraffic ? 'src/modules/mcp/manual-traffic-http.test.ts' : manualApiPing ? 'src/modules/mcp/manual-api-ping-http.test.ts' : simpleSpeedChr ? 'src/modules/mcp/simple-speed-chr.test.ts' : simpleSpeed ? 'src/modules/mcp/simple-speed-http.test.ts' : externalPeerMetadata ? 'src/services/bandwidth-external-peer-metadata.sql.test.ts' : operatorPeerMetadata ? 'src/services/bandwidth-operator-metadata.sql.test.ts' : publicRegistryMetadata ? 'src/services/bandwidth-public-registry.sql.test.ts' : operatorDockerSql ? 'src/services/bandwidth-operator-docker-stop.sql.test.ts' : probeOperationRetirement ? 'src/services/server-probe-operation-retirement.owned.test.ts' : ordinaryOperationRetirement ? 'src/modules/mcp/ordinary-operation-retirement.owned.test.ts' : pollOperationRetirement ? 'src/services/server-poll-operation-retirement.owned.test.ts' : mcpOperationOrigin ? 'src/modules/mcp/operation-execution-origin.sql.test.ts' : mcpSpeedOriginal ? 'src/modules/mcp/speed-bandwidth-original.sql.test.ts' : routerConfigurationGuard ? 'src/services/router-configuration-guard.sql.test.ts' : scheduledSpeedOriginal ? 'src/services/bandwidth-scheduled-speed-original.sql.test.ts' : bandwidthSpeedWorkflow ? 'src/services/bandwidth-speed-workflow.sql.test.ts' : restSpeedOriginal ? 'src/services/bandwidth-rest-speed-original.sql.test.ts' : operationLocks ? 'src/services/operation-config-lock-lease.sql.test.ts' : serviceExecutionOrigin ? 'src/modules/mcp/service-execution-origin.sql.test.ts' : bandwidthNodeSource ? 'src/services/bandwidth-node-source.sql.test.ts' : bandwidthExternalRegistry ? 'src/services/bandwidth-external-node-registry.sql.test.ts' : bandwidthNodeRegistry ? 'src/services/bandwidth-node-registry.sql.test.ts' : bandwidthControlSource ? 'src/services/bandwidth-control-source.sql.test.ts' : bandwidthAdmission ? 'src/services/bandwidth-admission.sql.test.ts' : serverPollStorageFresh ? 'src/services/server-poll-storage-fresh.sql.test.ts' : serverPollStorage ? 'src/services/server-poll-storage.sql.test.ts' : probeStorageFresh ? 'src/services/server-probe-storage-fresh.sql.test.ts' : probeLegacyOrphans ? 'src/services/server-probe-legacy-orphans.sql.test.ts' : diagnosticCancelHost ? 'src/modules/mcp/diagnostic-cancellation-storage.sql.test.ts' : ipsecExportHttp ? 'src/modules/mcp/ipsec-existing-certificate-export-http.sql.test.ts' : ipsecExportJournal ? 'src/services/ipsec-existing-certificate-export-rest-journal.sql.test.ts' : migrationDataInventory ? 'src/db/migration-data-inventory.sql.test.ts' : probeStorageHistory ? 'src/services/server-probe-storage-history.sql.test.ts' : probeStorageJournal ? 'src/services/server-probe-storage-journal.sql.test.ts' : controlledSchemaReference ? 'src/db/controlled-schema-reference.sql.test.ts' : controlledMigrations ? 'src/db/controlled-migrations.sql.test.ts' : databaseSchemaControl ? 'src/db/database-schema-control.sql.test.ts' : migrationSchemaInventory ? 'src/db/migration-schema-inventory.sql.test.ts' : routingNetworkNative ? 'src/modules/mcp/routing-network-native.sql.test.ts' : routingReservationRuntime ? 'src/modules/mcp/routing-reservation-runtime.sql.test.ts' : routingReservations ? 'src/modules/mcp/routing-reservation.sql.test.ts' : collectorPartitionWriters ? 'src/services/collector-partition-writes.sql.test.ts' : serverPollDurable ? 'src/services/server-poll-durable-session.sql.test.ts' : routingRecoveryAuthority ? 'src/modules/mcp/routing-recovery-authority.sql.test.ts' : legacyFlowRebuild ? 'src/test/legacy-flow-rebuild-sql.ts' : probeJournal ? 'src/services/server-probe-journal.sql.test.ts' : flowHostKeys ? 'src/modules/mcp/flow-host-keys-http.test.ts' : flowRebuildMcp ? 'src/modules/mcp/flow-facts-rebuild-http.test.ts' : flowRebuildLine truncated
const child = spawn(process.execPath, pollMemory ? ['--max-old-space-size=192', '--max-semi-space-size=16', fileURLToPath(new URL('../dist-mcp-memory/services/server-poll-memory.owned.test.js', import.meta.url))] : ['--import', 'tsx', usersBindings ? 'src/modules/mcp/users-bindings-owned.test.ts' : bridgeVlanConfiguration ? 'src/modules/mcp/bridge-vlan-configuration-http.test.ts' : principalSecuritySource ? 'src/modules/mcp/principal-security-source.owned.test.ts' : pollJournalSnapshot ? 'src/services/server-poll-journal-snapshot.owned.test.ts' : pollTerminalArchive ? 'src/services/server-poll-terminal-archive.owned.test.ts' : pollArchiveSchema ? 'src/db/server-poll-terminal-archives.sql.test.ts' : telemetrySettingsConfiguration ? 'src/modules/mcp/telemetry-settings-configuration.owned.test.ts' : routingReservationSchema ? 'src/db/routing-reservation-schema.owned.test.ts' : routingForwardRounds ? 'src/modules/mcp/routing-network-forward-rounds.owned.test.ts' : flowActiveServices ? 'src/services/traffic-flow-active-services.owned.test.ts' : flowActiveRebuild ? 'src/services/traffic-flow-active-rebuild.owned.test.ts' : flowQuality ? 'src/services/traffic-flow-quality.owned.test.ts' : flowWorkerIpc ? 'src/services/traffic-flow-worker-ipc.owned.test.ts' : manualUptime ? 'src/modules/mcp/manual-uptime-http.test.ts' : manualPingGroup ? 'src/modules/mcp/manual-ping-group-http.test.ts' : manualTraffic ? 'src/modules/mcp/manual-traffic-http.test.ts' : manualApiPing ? 'src/modules/mcp/manual-api-ping-http.test.ts' : simpleSpeedChr ? 'src/modules/mcp/simple-speed-chr.test.ts' : simpleSpeed ? 'src/modules/mcp/simple-speed-http.test.ts' : externalPeerMetadata ? 'src/services/bandwidth-external-peer-metadata.sql.test.ts' : operatorPeerMetadata ? 'src/services/bandwidth-operator-metadata.sql.test.ts' : publicRegistryMetadata ? 'src/services/bandwidth-public-registry.sql.test.ts' : operatorDockerSql ? 'src/services/bandwidth-operator-docker-stop.sql.test.ts' : probeOperationRetirement ? 'src/services/server-probe-operation-retirement.owned.test.ts' : ordinaryOperationRetirement ? 'src/modules/mcp/ordinary-operation-retirement.owned.test.ts' : pollOperationRetirement ? 'src/services/server-poll-operation-retirement.owned.test.ts' : mcpOperationOrigin ? 'src/modules/mcp/operation-execution-origin.sql.test.ts' : mcpSpeedOriginal ? 'src/modules/mcp/speed-bandwidth-original.sql.test.ts' : routerConfigurationGuard ? 'src/services/router-configuration-guard.sql.test.ts' : scheduledSpeedOriginal ? 'src/services/bandwidth-scheduled-speed-original.sql.test.ts' : bandwidthSpeedWorkflow ? 'src/services/bandwidth-speed-workflow.sql.test.ts' : restSpeedOriginal ? 'src/services/bandwidth-rest-speed-original.sql.test.ts' : operationLocks ? 'src/services/operation-config-lock-lease.sql.test.ts' : serviceExecutionOrigin ? 'src/modules/mcp/service-execution-origin.sql.test.ts' : bandwidthNodeSource ? 'src/services/bandwidth-node-source.sql.test.ts' : bandwidthExternalRegistry ? 'src/services/bandwidth-external-node-registry.sql.test.ts' : bandwidthNodeRegistry ? 'src/services/bandwidth-node-registry.sql.test.ts' : bandwidthControlSource ? 'src/services/bandwidth-control-source.sql.test.ts' : bandwidthAdmission ? 'src/services/bandwidth-admission.sql.test.ts' : serverPollStorageFresh ? 'src/services/server-poll-storage-fresh.sql.test.ts' : serverPollStorage ? 'src/services/server-poll-storage.sql.test.ts' : probeStorageFresh ? 'src/services/server-probe-storage-fresh.sql.test.ts' : probeLegacyOrphans ? 'src/services/server-probe-legacy-orphans.sql.test.ts' : diagnosticCancelHost ? 'src/modules/mcp/diagnostic-cancellation-storage.sql.test.ts' : ipsecExportHttp ? 'src/modules/mcp/ipsec-existing-certificate-export-http.sql.test.ts' : ipsecExportJournal ? 'src/services/ipsec-existing-certificate-export-rest-journal.sql.test.ts' : migrationDataInventory ? 'src/db/migration-data-inventory.sql.test.ts' : probeStorageHistory ? 'src/services/server-probe-storage-history.sql.test.ts' : probeStorageJournal ? 'src/services/server-probe-storage-journal.sql.test.ts' : controlledSchemaReference ? 'src/db/controlled-schema-reference.sql.test.ts' : controlledMigrations ? 'src/db/controlled-migrations.sql.test.ts' : databaseSchemaControl ? 'src/db/database-schema-control.sql.test.ts' : migrationSchemaInventory ? 'src/db/migration-schema-inventory.sql.test.ts' : routingNetworkNative ? 'src/modules/mcp/routing-network-native.sql.test.ts' : routingReservationRuntime ? 'src/modules/mcp/routing-reservation-runtime.sql.test.ts' : routingReservations ? 'src/modules/mcp/routing-reservation.sql.test.ts' : collectorPartitionWriters ? 'src/services/collector-partition-writes.sql.test.ts' : serverPollDurable ? 'src/services/server-poll-durable-session.sql.test.ts' : routingRecoveryAuthority ? 'src/modules/mcp/routing-recovery-authority.sql.test.ts' : legacyFlowRebuild ? 'src/test/legacy-flow-rebuild-sql.ts' : probeJournal ? 'src/services/server-probe-journal.sql.testLine truncated
cwd: pollMemory ? directory : new URL('../', import.meta.url), stdio: 'inherit', env: { ...(pollMemory ? pollMemoryEnvironment : process.env),
...(routingOriginalEffects ? { MMAPP_OWN_ROUTING_ORIGINAL_EFFECTS: 'true' } : {}),
DATABASE_URL: `postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres`,
@@ -257,7 +261,7 @@ try {
child.once('error', reject); child.once('exit', code => resolve(code ?? 1))
})
exitCode = result
if (result === 0 && !regressionOnly && !profilesOnly && !pollMemory && !principalSecuritySource && !pollJournalSnapshot && !pollArchiveSchema && !telemetrySettingsConfiguration && !routingReservationSchema && !routingForwardRounds && !flowActiveServices && !flowActiveRebuild && !flowQuality && !flowWorkerIpc && !manualUptime && !manualPingGroup && !manualTraffic && !manualApiPing && !simpleSpeed && !simpleSpeedChr && !externalPeerMetadata && !operatorPeerMetadata && !publicRegistryMetadata && !operatorDockerSql && !mcpOperationOrigin && !mcpSpeedOriginal && !routerConfigurationGuard && !scheduledSpeedOriginal && !bandwidthSpeedWorkflow && !restSpeedOriginal && !operationLocks && !serviceExecutionOrigin && !bandwidthNodeSource && !bandwidthNodeRegistry && !bandwidthExternalRegistry && !bandwidthControlSource && !bandwidthAdmission && !probeLegacyOrphans && !probeStorageJournal && !probeStorageHistory && !probeJournal && !chr && !flowWrites && !flowRebuild && !flowRebuildMcp && (process.argv.includes('--restore') || process.env.MMAPP_PG_CLIENT_BIN)) {
if (result === 0 && !regressionOnly && !profilesOnly && !usersBindings && !bridgeVlanConfiguration && !pollMemory && !principalSecuritySource && !pollJournalSnapshot && !pollArchiveSchema && !telemetrySettingsConfiguration && !routingReservationSchema && !routingForwardRounds && !flowActiveServices && !flowActiveRebuild && !flowQuality && !flowWorkerIpc && !manualUptime && !manualPingGroup && !manualTraffic && !manualApiPing && !simpleSpeed && !simpleSpeedChr && !externalPeerMetadata && !operatorPeerMetadata && !publicRegistryMetadata && !operatorDockerSql && !mcpOperationOrigin && !mcpSpeedOriginal && !routerConfigurationGuard && !scheduledSpeedOriginal && !bandwidthSpeedWorkflow && !restSpeedOriginal && !operationLocks && !serviceExecutionOrigin && !bandwidthNodeSource && !bandwidthNodeRegistry && !bandwidthExternalRegistry && !bandwidthControlSource && !bandwidthAdmission && !probeLegacyOrphans && !probeStorageJournal && !probeStorageHistory && !probeJournal && !chr && !flowWrites && !flowRebuild && !flowRebuildMcp && (process.argv.includes('--restore') || process.env.MMAPP_PG_CLIENT_BIN)) {
exitCode = await new Promise((resolve, reject) => {
const child = spawn(process.execPath, ['--import', 'tsx', 'src/modules/mcp/database-restore.test.ts'], {
cwd: new URL('../', import.meta.url), stdio: 'inherit', env: { ...process.env,
@@ -6,10 +6,12 @@ import { randomBytes, createHash } from 'node:crypto'
import { createServer } from 'node:net'
import { spawn } from 'node:child_process'
const allowed = new Set(['--default-scoped', '--main-only', '--check-only'])
if (process.argv.slice(2).some(arg => !allowed.has(arg)) || new Set(process.argv.slice(2)).size !== process.argv.slice(2).length || process.argv.includes('--default-scoped') && process.argv.includes('--main-only')) throw new Error('Usage: node backend/scripts/test-routing-controller-baseline.mjs [--default-scoped | --main-only] [--check-only]')
const modes = ['--default-scoped', '--main-only', '--scoped-bare-readback']
const allowed = new Set([...modes, '--check-only']), args = process.argv.slice(2)
const selected = modes.filter(flag => args.includes(flag))
if (args.some(arg => !allowed.has(arg)) || new Set(args).size !== args.length || selected.length > 1) throw new Error('Usage: node backend/scripts/test-routing-controller-baseline.mjs [--default-scoped | --main-only | --scoped-bare-readback] [--check-only]')
const root = resolve(import.meta.dirname, '../..'), require = createRequire(import.meta.url), ts = require('typescript')
const mode = process.argv.includes('--default-scoped') ? 'default-scoped' : process.argv.includes('--main-only') ? 'main-only' : 'main-and-forward-rounds'
const mode = selected[0]?.slice(2) ?? 'main-and-forward-rounds'
const target = join(root, 'backend/src/modules/mcp/routing-controller-active-writer.ts')
await readFile(target)
await readFile(join(root, 'backend/src/modules/mcp/routing-main-transition.ts'))
+13 -7
View File
@@ -38,6 +38,7 @@ export function projectVrfDiagnostics(log, privateValues = [], expectedSlice = n
if (Array.isArray(value.accepted)) { row.finalCaseCount = value.accepted.length; if (expectedSlice !== null && value.slice === expectedSlice) finalComponentPresent = true }
if (['planned','queued','running','applied','failed','cancelled','unknown','expired','rejected','unconfirmed'].includes(value.operationState)) row.operationState = value.operationState
for (const key of ['phase', 'elapsedMs', 'nativeReads', 'nativeWrites', 'roundTrips', 'lastSqlMs', 'totalSqlMs', 'maxSqlMs']) if (Number.isSafeInteger(value[key]) && value[key] >= 0) row[key] = value[key]
if (Number.isSafeInteger(value.wireguardPeerTelemetryTicks) && value.wireguardPeerTelemetryTicks > 0 && value.wireguardPeerTelemetryTicks <= 10000) row.wireguardPeerTelemetryTicks = value.wireguardPeerTelemetryTicks
if (value.diagnosticWaitSql === true) row.diagnosticWaitSql = true
if (value.diagnosticError === true) { row.diagnosticError = true; row.safeName = diagnosticNames.has(value.safeName) ? value.safeName : 'other'; row.safeCode = diagnosticCodes.has(value.safeCode) ? value.safeCode : 'other' }
if (!Object.keys(row).length) { omittedLines++; continue }
@@ -47,10 +48,10 @@ export function projectVrfDiagnostics(log, privateValues = [], expectedSlice = n
}
export function vrfSliceDiagnostic(result, log, childBudgetMs, privateValues = []) {
assert(result && typeof result === 'object')
assert(['baseline','physical','vlan','disabled','mixed','reconcile','sql-journal','guards','coexistence','alias'].includes(result.slice))
assert(['baseline','physical','vlan','disabled','mixed','reconcile','sql-journal','guards','coexistence','alias','gre'].includes(result.slice))
assert(Number.isSafeInteger(result.code) && typeof result.childExitConfirmed === 'boolean')
assert(Number.isSafeInteger(result.caseCount) && result.caseCount >= 0)
assert.equal(childBudgetMs, result.slice === 'baseline' ? 240000 : 120000)
assert.equal(childBudgetMs, result.slice === 'baseline' ? 240000 : result.slice === 'disabled' ? 180000 : 120000)
assert(result.failure === null || ['child-deadline','child-log-limit','child-signal','child-error','child-error-after-pid','child-exit-unconfirmed','missing-component-receipt'].includes(result.failure))
assert(result.accepted === (result.code === 0 && result.failure === null && result.childExitConfirmed))
return { ...result, capMs: childBudgetMs, diagnostics: projectVrfDiagnostics(log, privateValues, result.slice), scope: 'same-owned-PG-test-only', actualRouter: false, forwardingVerified: false }
@@ -88,10 +89,14 @@ export async function runOwnedVrfProductionProfile({ directory, database, databa
const report = fileURLToPath(new URL('../../docs/mcp-chr-vrf-companion-native-v5.json', import.meta.url))
const loader = pathToFileURL(createRequire(import.meta.url).resolve('tsx')).href
const safe = Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'LANG', 'LC_ALL'].filter(key => typeof process.env[key] === 'string').map(key => [key, process.env[key]]))
for (const slice of ['baseline', 'physical', 'vlan', 'disabled', 'mixed', 'reconcile', 'sql-journal', 'guards', 'coexistence', 'alias']) {
// Diagnose the disabled lifecycle first, then the synthetic GRE extension.
// Every original case remains required by the same full74 gate and caps.
for (const slice of ['disabled', 'gre', 'baseline', 'physical', 'vlan', 'mixed', 'reconcile', 'sql-journal', 'guards', 'coexistence', 'alias']) {
currentSlice = slice; currentChildExitConfirmed = false; lastSliceResult = null
await persist(slice + '-started.json', { version: 1, slice, status: 'running', accepted: false, childExitConfirmed: false })
const childBudgetMs = slice === 'baseline' ? 240000 : 120000
// Nine disabled lifecycle operations need their own aggregate child cap;
// measured seven operations alone consumed 105.5s under the original120s.
const childBudgetMs = slice === 'baseline' ? 240000 : slice === 'disabled' ? 180000 : 120000
const cwd = join(owned, 'vrf-production-' + slice), rel = relative(owned, cwd)
assert(rel && !rel.startsWith('..') && !isAbsolute(rel)); await mkdir(cwd)
assert.equal(await realpath(cwd), cwd); assert(!(await lstat(cwd)).isSymbolicLink())
@@ -121,9 +126,10 @@ export async function runOwnedVrfProductionProfile({ directory, database, databa
unknownTimer = setTimeout(() => { if (!exited) { stop('child-exit-unconfirmed'); resolveExit(1) } }, childBudgetMs + 15000)
})
clearTimeout(deadline); clearTimeout(stopTimer); clearTimeout(unknownTimer)
const expectedCases = { baseline: 11, physical: 7, vlan: 9, disabled: 9, mixed: 7, reconcile: 6, 'sql-journal': 1, guards: 11, coexistence: 3, alias: 4 }
const expectedCases = { baseline: 11, physical: 7, vlan: 9, disabled: 9, mixed: 7, reconcile: 6, 'sql-journal': 1, guards: 11, coexistence: 3, alias: 4, gre: 6 }
const component = log.trim().split(/\r?\n/).flatMap(line => { try { return [JSON.parse(line)] } catch { return [] } }).findLast(value => value.slice === slice && Array.isArray(value.accepted))
if (!component || component.accepted.length !== expectedCases[slice] || component.actualSql !== true || component.actualCrypto !== true || component.actualAuthPolicyApprovalRuntime !== true || component.fakeVerifier !== false || component.actualRouter !== false || component.forwardingVerified !== false) failure ??= 'missing-component-receipt'
if (slice === 'gre' && (!Number.isSafeInteger(component?.wireguardPeerTelemetryTicks) || component.wireguardPeerTelemetryTicks <= 6 || component.wireguardPeerTelemetryTicks > 10000)) failure ??= 'missing-component-receipt'
const result = { slice, accepted: code === 0 && !failure && exited, caseCount: component?.accepted?.length ?? 0, code, failure, childPid: child.pid ?? null, childExitConfirmed: exited }
lastSliceResult = result; currentChildExitConfirmed = exited
const diagnostic = vrfSliceDiagnostic(result, log, childBudgetMs, [databaseUrl, target.password, decodeURIComponent(target.password), authSecret])
@@ -135,8 +141,8 @@ export async function runOwnedVrfProductionProfile({ directory, database, databa
console.log(JSON.stringify({ profile: 'test:vrf:production', ...result }))
if (!result.accepted) break
}
const receipt = { accepted: results.length === 10 && results.every(result => result.accepted), childExitConfirmed: results.every(result => result.childExitConfirmed), sameOwnedPg: true, actualRouter: false, forwardingVerified: false, results }
await persist('terminal.json', { version: 1, status: 'terminal', ...receipt, requiredCases: 68, completedCases: results.reduce((sum, value) => sum + value.caseCount, 0), diagnosticDirectory: diagnosticPath })
const receipt = { accepted: results.length === 11 && results.every(result => result.accepted), childExitConfirmed: results.every(result => result.childExitConfirmed), sameOwnedPg: true, actualRouter: false, forwardingVerified: false, results }
await persist('terminal.json', { version: 1, status: 'terminal', ...receipt, requiredCases: 74, completedCases: results.reduce((sum, value) => sum + value.caseCount, 0), diagnosticDirectory: diagnosticPath })
await writeFile(join(owned, 'vrf-production-acceptance.json'), JSON.stringify(receipt, null, 2))
return receipt
} catch {
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { vrfSliceDiagnostic } from './vrf-production-profile.mjs'
test('only the nine-operation disabled slice receives its measured aggregate budget', () => {
for (const [slice, capMs] of [['disabled', 180000], ['gre', 120000], ['baseline', 240000]]) {
const result = { slice, accepted: false, caseCount: 0, code: 1, failure: 'child-deadline', childExitConfirmed: true }
assert.equal(vrfSliceDiagnostic(result, '', capMs).capMs, capMs)
for (const otherCap of [120000, 180000, 240000].filter(value => value !== capMs)) assert.throws(() => vrfSliceDiagnostic(result, '', otherCap))
}
})
test('GRE lifecycle diagnostic retains the six-case receipt with existing privacy bounds', () => {
const accepted = ['vrf-create', 'vrf-rename', 'vrf-disable', 'vrf-reenable', 'vrf-membership-transfer-a-to-b', 'vrf-delete-addresses-remain']
const result = { slice: 'gre', accepted: true, caseCount: 6, code: 0, failure: null, childExitConfirmed: true }
const log = accepted.map((acceptedCase, index) => JSON.stringify({ slice: 'gre', acceptedCase, nativeWrites: 1, wireguardPeerTelemetryTicks: (index + 1) * 4, password: 'fixture-private-value' }))
log.push(JSON.stringify({ slice: 'gre', accepted, wireguardPeerTelemetryTicks: 24, rawShape: 'synthetic GRE lifecycle', password: 'fixture-private-value' }))
const diagnostic = vrfSliceDiagnostic(result, log.join('\n'), 120000, ['fixture-private-value'])
assert.deepEqual(diagnostic.diagnostics.acceptedCaseSequence, accepted)
assert.equal(diagnostic.diagnostics.finalComponentPresent, true)
assert.equal(diagnostic.diagnostics.rawLogRetained, false)
assert.deepEqual(diagnostic.diagnostics.records.map(row => row.wireguardPeerTelemetryTicks), [4, 8, 12, 16, 20, 24, 24])
assert.equal(diagnostic.actualRouter, false)
assert.equal(diagnostic.forwardingVerified, false)
assert.equal(JSON.stringify(diagnostic).includes('fixture-private-value'), false)
assert.throws(() => vrfSliceDiagnostic(result, '', 240000))
})
test('peer telemetry diagnostic rejects nonnumeric and out-of-budget values', () => {
const result = { slice: 'gre', accepted: false, caseCount: 0, code: 1, failure: 'missing-component-receipt', childExitConfirmed: true }
for (const wireguardPeerTelemetryTicks of [0, -1, 10001, 1.5, 'private-raw-value', { secret: 'private-raw-value' }]) {
const diagnostic = vrfSliceDiagnostic(result, JSON.stringify({ slice: 'gre', wireguardPeerTelemetryTicks }), 120000)
assert.equal(diagnostic.diagnostics.records.some(row => Object.hasOwn(row, 'wireguardPeerTelemetryTicks')), false)
assert.equal(JSON.stringify(diagnostic).includes('private-raw-value'), false)
}
})
test('a partial GRE lifecycle is diagnostic evidence without a final component receipt', () => {
const result = { slice: 'gre', accepted: false, caseCount: 0, code: 1, failure: 'child-deadline', childExitConfirmed: true }
const diagnostic = vrfSliceDiagnostic(result, JSON.stringify({ slice: 'gre', acceptedCase: 'vrf-create' }), 120000)
assert.equal(diagnostic.accepted, false)
assert.equal(diagnostic.diagnostics.finalComponentPresent, false)
assert.equal(diagnostic.diagnostics.lastAcceptedCase, 'vrf-create')
assert.equal(diagnostic.capMs, 120000)
})
+2 -2
View File
@@ -7,7 +7,7 @@ export function approvedDefinition(value:unknown){
assert(value&&typeof value==='object');const v=value as Record<string,unknown>
assert.deepEqual(Object.keys(v).sort(),['format','provenance','template']);assert.equal(v.format,'application-writer-source44-definition-approval-v1')
assert.equal(canonical(v.provenance),canonical(BUILD44_PROVENANCE));assert.equal(canonical(v.template),canonical(FIXED44_ROLE_TEMPLATE))
return BUILD44_PROVENANCE.templateSha256
return Object.freeze({definitionSha256:BUILD44_PROVENANCE.templateSha256,manifestSha256:BUILD44_PROVENANCE.manifestSha256})
}
/** Real incremental DATA verifier on caller's retained transaction. No pool or attestor. */
export async function collectSource44Catalogue(s:{query:(sql:string,values?:unknown[])=>Promise<{rows:Record<string,unknown>[]} >},installation:{roleBindings:ConstructorParameters<typeof Fixed44CatalogueValueBridgeData>[0]}){
@@ -21,7 +21,7 @@ export async function collectSource44Catalogue(s:{query:(sql:string,values?:unkn
await s.query('DECLARE mm_publisher44 NO SCROLL CURSOR WITHOUT HOLD FOR '+FIXED44_CATALOGUE_BUILD_SQL,values);cursor=true
for(;;){const page=(await s.query('FETCH FORWARD 128 FROM mm_publisher44')).rows;bridge.acceptPage(page);if(page.length===0)break}
const result=bridge.finish();await s.query('CLOSE mm_publisher44');cursor=false
return {catalogSha256:sha(canonical({values:result.values.snapshotSha256,catalogue:result.catalogue})),definitionSha256:result.catalogue.definitionSha256,leafCount:result.catalogue.leafRelations}
return {catalogSha256:sha(canonical({values:result.values.snapshotSha256,catalogue:result.catalogue})),definitionSha256:result.catalogue.definitionSha256,manifestSha256:result.catalogue.manifestSha256,leafCount:result.catalogue.leafRelations}
}catch(error){primary=error;failed=true;throw error}
finally{
try{if(cursor)await s.query('CLOSE mm_publisher44');await s.query("SELECT pg_catalog.set_config('search_path',$1,true),pg_catalog.set_config('TimeZone',$2,true),pg_catalog.set_config('DateStyle',$3,true)",[settings[0].path,settings[0].zone,settings[0].dates])}
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'
import {X509Certificate} from 'node:crypto'
import {checkServerIdentity} from 'node:tls'
import {checkServerIdentity,TLSSocket} from 'node:tls'
import {sha} from './operator-files.mjs'
export const roleKeys=Object.freeze(['runtime','operator','capture','publisher','control'])
export const STARTUP_OPTIONS='-c statement_timeout=10000 -c lock_timeout=5000 -c client_encoding=UTF8'
@@ -18,15 +18,29 @@ export function approval(value){
}
// Pinned historical inspection data only; never publication authorization.
export function reconciliationApproval(value){return parsedApproval(value)}
function peerError(pin,host,cert){
const failed=checkServerIdentity(pin.serverName,cert);if(failed)return failed
try{if(host!==pin.serverName||sha(new X509Certificate(cert.raw).publicKey.export({format:'der',type:'spki'}))!==pin.spkiSha256)return Error('Publisher TLS SPKI refused')}catch{return Error('Publisher TLS certificate refused')}
}
// pg replaces connection.stream with the negotiated TLS socket. Hold its
// identity on the retained client; handshake callbacks are not session evidence.
export function retainPublisherTlsPeer(client,pin){
const socket=client.connection?.stream
const assertCurrent=()=>{
assert(socket instanceof TLSSocket&&client.connection?.stream===socket&&socket.encrypted&&socket.authorized&&!socket.destroyed&&socket.writable,'Publisher retained TLS peer unavailable')
}
assertCurrent()
const failed=peerError(pin,socket.servername,socket.getPeerCertificate());if(failed)throw failed
socket.disableRenegotiation()
return assertCurrent
}
export function poolOptions(transport,request,ca){
assert(exact(transport,['host','port','user','password','database']))
assert(typeof transport.host==='string'&&transport.host===request.tls.serverName&&Number.isSafeInteger(transport.port)&&transport.port>0&&transport.port<=65535&&transport.user===request.roles.publisher.name&&typeof transport.password==='string'&&transport.password.length>0&&transport.password.length<=4096&&transport.database===request.source.databaseName)
assert(sha(ca)===request.tls.caSha256);new X509Certificate(ca)
return {host:transport.host,port:transport.port,user:transport.user,password:transport.password,database:transport.database,max:1,connectionTimeoutMillis:5000,idleTimeoutMillis:5000,statement_timeout:10000,lock_timeout:5000,options:STARTUP_OPTIONS,application_name:'mmapp-writer-publisher',client_encoding:'UTF8',
ssl:{ca,servername:request.tls.serverName,rejectUnauthorized:true,checkServerIdentity(host,cert){
const failed=checkServerIdentity(request.tls.serverName,cert);if(failed)return failed
try{if(host!==request.tls.serverName||sha(new X509Certificate(cert.raw).publicKey.export({format:'der',type:'spki'}))!==request.tls.spkiSha256)return Error('Publisher TLS SPKI refused')}catch{return Error('Publisher TLS certificate refused')}
}}}
ssl:{ca,servername:request.tls.serverName,rejectUnauthorized:true,checkServerIdentity:(host,cert)=>peerError(request.tls,host,cert)}}
}
export function assertPublicationApproval(current,catalogue,structure,pinned,manifestSha256){
assert(pinned.expiresAt>Date.now()&&pinned.manifestSha256===manifestSha256&&pinned.definitionSha256===catalogue.definitionSha256)
+39 -17
View File
@@ -3,6 +3,7 @@ import pg from 'pg'
import {createPrivateKey,createPublicKey,sign} from 'node:crypto'
import {open,lstat,realpath,unlink} from 'node:fs/promises'
import {writeFile,withJournalLease} from './operator-files.mjs'
import {retainPublisherTlsPeer} from './operator-contract.mjs'
import path from 'node:path'
import {captureDatabaseSourceObservation,assertDatabaseSourceObservationCurrent} from '../../src/db/database-source-observation.js'
import {MIGRATION_MANIFEST,migrationManifestDigest} from '../../src/db/migration-manifest.js'
@@ -10,7 +11,13 @@ import {MIGRATION_MANIFEST,migrationManifestDigest} from '../../src/db/migration
import {ownedContext,sameOwned,sourceCoordinates,protectedStructure,readCurrent,HISTORY,CURRENT} from './context.mjs'
import {bytes,canonical,sha,nextEpoch,POLICY_DOMAIN,HEAD_DOMAIN,KEY,liveValidity,policySchema,headSchema,publicKey} from '../../src/db/application-writer-profile-format.js'
export function createConnectedPublisher(installation,collectFixedCatalogue,sourceVerifier,approval){
export function requireCurrentCatalogueDefinition(approval,definition){
const manifestSha256=migrationManifestDigest()
if(approval.manifestSha256!==manifestSha256||definition.manifestSha256!==manifestSha256||definition.definitionSha256!==approval.definitionSha256)throw Error('Approved catalogue definition is not bound to this build migration manifest')
return manifestSha256
}
export function createConnectedPublisher(installation,collectFixedCatalogue,sourceVerifier,approval,definition){
const PUBLISHER=installation.roleBindings.publisher.name
class ProfilePublicationError extends Error{
automaticReplay=false
@@ -29,13 +36,18 @@ async function privateKey(filename){
// detached application callbacks or cleanup/COMMIT races.
async function session(pool,deadlineAt,work){
if(!(pool instanceof pg.Pool)||!Number.isSafeInteger(deadlineAt)||deadlineAt<=Date.now()||deadlineAt-Date.now()>120000)throw Error('Bounded genuine publisher pool/lifetime required')
let client,retired=false,released=false,locked=false,begun=false,commitSent=false,committed=false,confirmedValue,proposal,phase='checkout',stopReject,cleanupUnconfirmed=false
let client,assertTransport,retired=false,released=false,locked=false,begun=false,commitSent=false,committed=false,confirmedValue,proposal,phase='checkout',stopReject,cleanupUnconfirmed=false
const stopped=new Promise((_,reject)=>{stopReject=reject});void stopped.catch(()=>{})
const release=bad=>{if(!client||released)return;released=true;client.off('error',fault);client.off('end',fault);try{client.release(bad)}catch{cleanupUnconfirmed=true}}
const fault=()=>{if(retired)return;retired=true;cleanupUnconfirmed=true;release(true);stopReject(Error('Publisher connection/lifetime ended'))}
const timer=setTimeout(fault,Math.max(1,deadlineAt-Date.now())),bounded=p=>Promise.race([p,stopped])
const query=async(text,values)=>{if(retired||Date.now()>=deadlineAt)throw Error('Publisher lifetime closed');return bounded(client.query(text,values))}
const fence=()=>{
if(retired||Date.now()>=deadlineAt)throw Error('Publisher lifetime closed')
try{assertTransport()}catch(error){fault();throw error}
}
const query=async(text,values)=>{fence();return bounded(client.query(text,values))}
const state={query,phase:value=>{phase=value},proposal:value=>{proposal=Object.freeze(value)},begun:value=>{begun=value},commit:async value=>{
fence()
confirmedValue=Object.freeze({...value})
commitSent=true
const response=await bounded(client.query('COMMIT').then(result=>{if(result.command==='COMMIT'){committed=true;begun=false}return result}))
@@ -45,6 +57,7 @@ async function session(pool,deadlineAt,work){
try{
const pending=pool.connect().then(value=>{client=value;if(retired){release(true);return value}client.on('error',fault);client.on('end',fault);return value});void pending.catch(()=>{})
await bounded(pending);if(retired)throw Error('Late publisher checkout')
try{assertTransport=retainPublisherTlsPeer(client,installation.tls)}catch(error){retired=true;release(true);throw error}
await query("SET statement_timeout='10s'");await query("SET lock_timeout='5s'")
phase='exclusive-admission';await query('SELECT pg_catalog.pg_advisory_lock($1::bigint)',[KEY]);locked=true
value=await work(state)
@@ -62,7 +75,16 @@ async function session(pool,deadlineAt,work){
if(failed||cleanupUnconfirmed||retired)throw new ProfilePublicationError(commitSent||begun?'unknown':'not-committed',phase,proposal,cleanupUnconfirmed||retired,primary)
return value
}
async function publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256,journalDirectory,validityMs=86400000,deadlineAt=Date.now()+120000}){
async function observeCurrentCatalogue(s,manifestSha256){
const security=await protectedStructure(s,installation),catalogue=await collectFixedCatalogue(s)
if(catalogue.manifestSha256!==manifestSha256||catalogue.definitionSha256!==definition.definitionSha256)throw Error('Catalogue evidence differs from the approved migration manifest/definition')
// Inspect one extra row so an unexpected marker cannot hide after the expected prefix.
const expected=MIGRATION_MANIFEST.map(pin=>pin.file.slice(0,-4))
const markers=(await s.query('SELECT id FROM public.schema_migrations ORDER BY id COLLATE "C" LIMIT $1',[expected.length+1])).rows.map(row=>row.id)
if(canonical(markers)!==canonical(expected))throw Error('Exact current migration marker metadata required')
return {security,catalogue,catalogSha256:sha(canonical({base:catalogue.catalogSha256,security:security.sha256}))}
}
async function publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256,journalDirectory,validityMs=86400000,deadlineAt=Date.now()+120000},manifestSha256){
if(!Number.isSafeInteger(validityMs)||validityMs<1||validityMs>86400000||expectedHeadSha256!==null&&!(/^[a-f0-9]{64}$/).test(expectedHeadSha256))throw Error('Explicit previous head and bounded validity required')
const key=await privateKey(privateKeyFile),directory=await realpath(journalDirectory),pendingFile=path.join(directory,'pending-publication.json')
try{await lstat(pendingFile);throw Error('Pending publication must be reconciled before signing')}catch(error){if(error.code!=='ENOENT')throw error}
@@ -73,18 +95,15 @@ async function publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256,jou
const observation=await captureDatabaseSourceObservation(facade,options)
await sourceVerifier.verify(facade,observation,options)
if(canonical(sourceCoordinates(first))!==canonical(installation.expectedSource))throw Error('Pinned source mismatch')
const security=await protectedStructure(s,installation),catalogue=await collectFixedCatalogue(s)
const markers=(await s.query('SELECT id FROM public.schema_migrations ORDER BY id LIMIT 45')).rows.map(row=>row.id)
if(canonical(markers)!==canonical(MIGRATION_MANIFEST.map(pin=>pin.file.slice(0,-4))))throw Error('Exact current migration marker metadata required')
const {security,catalogue,catalogSha256}=await observeCurrentCatalogue(s,manifestSha256)
const old=await readCurrent(s,installation,{allowEmpty:true,validNow:false})
if((old?.headSha256??null)!==expectedHeadSha256)throw Error('Expected protected current head mismatch')
const catalogSha256=sha(canonical({base:catalogue.catalogSha256,security:security.sha256}))
if(approval.expiresAt<=Date.now()||approval.manifestSha256!==migrationManifestDigest()||approval.definitionSha256!==catalogue.definitionSha256||approval.catalogSha256!==catalogSha256)throw Error('Independently approved policy differs from actual source44 catalogue')
if(old&&(canonical(old.policy.source)!==canonical(sourceCoordinates(first))||canonical(old.policy.roles)!==canonical(security.roles)||old.policy.manifestSha256!==migrationManifestDigest()||old.policy.definitionSha256!==catalogue.definitionSha256||old.policy.catalogSha256!==catalogSha256))throw Error('Renewal cannot adopt a changed source/subject/policy')
if(approval.expiresAt<=Date.now()||approval.catalogSha256!==catalogSha256)throw Error('Independently approved policy differs from actual catalogue')
if(old&&(canonical(old.policy.source)!==canonical(sourceCoordinates(first))||canonical(old.policy.roles)!==canonical(security.roles)||old.policy.manifestSha256!==manifestSha256||old.policy.definitionSha256!==catalogue.definitionSha256||old.policy.catalogSha256!==catalogSha256))throw Error('Renewal cannot adopt a changed source/subject/policy')
await sourceVerifier.verify(facade,observation,options);await assertDatabaseSourceObservationCurrent(facade,observation,options);sameOwned(first,await ownedContext(s,installation,PUBLISHER,'publisher-read'))
await s.query('COMMIT');s.begun(false)
const number=old?nextEpoch(old.epoch):'1',now=Date.now()
const body=policySchema.parse({format:'application-writer-policy-v1',installationId:installation.installationId,epoch:number,issuedAt:now,notBefore:now,notAfter:now+validityMs,source:sourceCoordinates(first),sourceObservationSha256:sha(canonical(observation)),roles:security.roles,manifestSha256:migrationManifestDigest(),definitionSha256:catalogue.definitionSha256,catalogSha256,futureLeaves:'fixed12-native-policy-v1'})
const body=policySchema.parse({format:'application-writer-policy-v1',installationId:installation.installationId,epoch:number,issuedAt:now,notBefore:now,notAfter:now+validityMs,source:sourceCoordinates(first),sourceObservationSha256:sha(canonical(observation)),roles:security.roles,manifestSha256,definitionSha256:catalogue.definitionSha256,catalogSha256,futureLeaves:'fixed12-native-policy-v1'})
const envelope=(domain,body)=>bytes({body,signature:sign(null,Buffer.from(domain+canonical(body)),key).toString('base64url')})
const policyBytes=envelope(POLICY_DOMAIN,body),policySha256=sha(policyBytes)
const head=headSchema.parse({format:'application-writer-head-v1',installationId:installation.installationId,epoch:number,policySha256,previousHeadSha256:old?.headSha256??null})
@@ -101,8 +120,8 @@ async function publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256,jou
await s.query(`SELECT id FROM mmapp_control.${CURRENT} WHERE id=1 FOR UPDATE`)
const current=await readCurrent(s,installation,{allowEmpty:true,validNow:false})
if((current?.headSha256??null)!==expectedHeadSha256)throw Error('CAS predecessor changed')
const againSecurity=await protectedStructure(s,installation),againCatalogue=await collectFixedCatalogue(s)
if(sha(canonical({base:againCatalogue.catalogSha256,security:againSecurity.sha256}))!==catalogSha256)throw Error('Policy drift during retained exclusive publication')
const again=await observeCurrentCatalogue(s,manifestSha256)
if(again.catalogSha256!==catalogSha256)throw Error('Policy drift during retained exclusive publication')
if(Date.now()>=body.notAfter||Date.now()>=approval.expiresAt)throw Error('Policy or approval expired before publication dispatch')
await s.query(`INSERT INTO mmapp_control.${HISTORY}(installation_id,epoch,head_sha256,previous_installation_id,previous_epoch,previous_head_sha256,policy_sha256,policy_bytes,head_bytes) VALUES($1::uuid,$2::bigint,$3,$4::uuid,$5::bigint,$6,$7,$8::bytea,$9::bytea)`,[installation.installationId,number,headSha256,old?installation.installationId:null,old?.epoch??null,old?.headSha256??null,policySha256,policyBytes,headBytes])
const changed=old?await s.query(`UPDATE mmapp_control.${CURRENT} SET epoch=$1::bigint,head_sha256=$2 WHERE id=1 AND installation_id=$3::uuid AND epoch=$4::bigint AND head_sha256=$5 RETURNING id`,[number,headSha256,installation.installationId,old.epoch,old.headSha256]):await s.query(`INSERT INTO mmapp_control.${CURRENT}(id,installation_id,epoch,head_sha256) VALUES(1,$1::uuid,$2::bigint,$3) RETURNING id`,[installation.installationId,number,headSha256])
@@ -136,17 +155,17 @@ async function inspectPublication(pool,proposal,{deadlineAt=Date.now()+120000}={
// Read-only diagnostics on the actual protected publisher session. This does
// not admit application/operator writes, bootstrap, restore, or READY.
async function preflightCurrentProfile(pool,{deadlineAt=Date.now()+120000}={}){
const manifestSha256=requireCurrentCatalogueDefinition(approval,definition)
return session(pool,deadlineAt,async s=>{
s.phase('profile-preflight');await s.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');s.begun(true)
const first=await ownedContext(s,installation,PUBLISHER,'publisher-read')
if(canonical(sourceCoordinates(first))!==canonical(installation.expectedSource))throw Error('Pinned preflight source mismatch')
const facade=Object.freeze({query:(sql,values)=>s.query(sql,values)}),options={deadlineAt}
const observation=await captureDatabaseSourceObservation(facade,options),initialSource=await sourceVerifier.verify(facade,observation,options)
const security=await protectedStructure(s,installation),catalogue=await collectFixedCatalogue(s)
const {security,catalogue,catalogSha256}=await observeCurrentCatalogue(s,manifestSha256)
const current=await readCurrent(s,installation)
if(!current)throw Error('Current signed policy required for preflight')
const catalogSha256=sha(canonical({base:catalogue.catalogSha256,security:security.sha256})),manifestSha256=migrationManifestDigest()
if(approval.expiresAt<=Date.now()||approval.manifestSha256!==manifestSha256||approval.definitionSha256!==catalogue.definitionSha256||approval.catalogSha256!==catalogSha256)throw Error('Independent preflight approval differs from actual source44 catalogue')
if(approval.expiresAt<=Date.now()||approval.catalogSha256!==catalogSha256)throw Error('Independent preflight approval differs from actual catalogue')
if(canonical(current.policy.source)!==canonical(sourceCoordinates(first))||canonical(current.policy.roles)!==canonical(security.roles)||current.policy.manifestSha256!==manifestSha256||current.policy.definitionSha256!==catalogue.definitionSha256||current.policy.catalogSha256!==catalogSha256)throw Error('Current signed policy differs from actual source/roles/catalogue')
const terminalSource=await sourceVerifier.verify(facade,observation,options);await assertDatabaseSourceObservationCurrent(facade,observation,options)
sameOwned(first,await ownedContext(s,installation,PUBLISHER,'publisher-read'))
@@ -173,7 +192,10 @@ async function reconcilePendingPublication(pool,{journalDirectory,deadlineAt=Dat
}
return Object.freeze({
preflightCurrentProfile,
publishCurrentProfile:(pool,options)=>withJournalLease(options.journalDirectory,()=>publishCurrentProfile(pool,options)),
publishCurrentProfile:async(pool,options)=>{
const manifestSha256=requireCurrentCatalogueDefinition(approval,definition)
return withJournalLease(options.journalDirectory,()=>publishCurrentProfile(pool,options,manifestSha256))
},
inspectPublication,
reconcilePendingPublication:(pool,options)=>withJournalLease(options.journalDirectory,()=>reconcilePendingPublication(pool,options)),
})
@@ -0,0 +1,150 @@
/** Deployment operator state. Store outside PGDATA, backups and runtime mounts. */
import assert from 'node:assert/strict'
import {constants} from 'node:fs'
import {lstat,open,rename} from 'node:fs/promises'
import path from 'node:path'
import {z} from 'zod'
import {sourceBindingBytes,sourceBindingCanonical,type DatabaseSourceBindingBody} from '../../src/db/database-source-binding.js'
import {protectedDirectory,readProtected,sha,withJournalLease} from './operator-files.mjs'
import {readProtectedDatabaseSourceBindingRegistry, type createProtectedDatabaseSourceBindingVerifier} from './protected-source-registry.mjs'
const digest=z.string().regex(/^[a-f0-9]{64}$/),uuid=z.string().uuid()
const sourcePin=z.object({registryDirectory:z.string().min(1).max(4096),installationId:uuid,publicKeySpkiSha256:digest,currentHeadSha256:digest}).strict()
const directoryPath=z.string().min(1).max(4096)
const approval=z.object({format:z.literal('database-source-activation-approval-v1'),activationDirectory:directoryPath,sourcePin,tlsSpkiSha256:digest,
expectedActivationSha256:digest.nullable()}).strict()
const generation=z.object({generation:uuid,headSha256:digest,bindingSha256:digest,tlsSpkiSha256:digest}).strict()
const stateSchema=z.object({format:z.literal('database-source-activation-v1'),activationDirectory:directoryPath,installationId:uuid,sourceIdentityId:uuid,
publicKeySpkiSha256:digest,status:z.enum(['active','revoked']),generations:z.array(generation).min(1).max(256)}).strict()
const maximum=131072, filename='source-activation.json'
type State=z.infer<typeof stateSchema>
const parse=(bytes:Buffer):unknown=>JSON.parse(new TextDecoder('utf8',{fatal:true}).decode(bytes))
async function present(file:string){
try{await lstat(file);return true}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')return false;throw error}
}
// Caller holds the same protected lease used by all activation writers.
async function current(directory:string){
const file=path.join(directory,filename)
assert(!await present(file+'.pending'),'Unresolved activation transition')
if(!await present(file))return undefined
const bytes=await readProtected(file,maximum),state=stateSchema.parse(parse(bytes))
assert.equal(state.activationDirectory,directory,'Activation record belongs to another directory')
assert(sourceBindingBytes(state).equals(bytes),'Noncanonical activation record')
for(const field of ['generation','headSha256','tlsSpkiSha256'] as const){
assert.equal(new Set(state.generations.map(item=>item[field].toLowerCase())).size,state.generations.length,'Reused activation identity')
}
return {state,activationSha256:sha(bytes)}
}
async function persist(directory:string,state:State){
await protectedDirectory(directory)
const bytes=sourceBindingBytes(stateSchema.parse(state))
assert(bytes.length<=maximum)
const file=path.join(directory,filename),pending=file+'.pending'
const parent=await open(directory,constants.O_RDONLY|constants.O_DIRECTORY|constants.O_NOFOLLOW)
let confirmed=false,cleanupUnconfirmed=false
try{
const handle=await open(pending,constants.O_WRONLY|constants.O_CREAT|constants.O_EXCL|constants.O_NOFOLLOW,0o600)
try{await handle.writeFile(bytes);await handle.sync()}finally{await handle.close()}
try{await rename(pending,file);await parent.sync()}
catch{throw Object.assign(Error('Activation terminal requires inspection'),{code:'SOURCE_ACTIVATION_TERMINAL_UNKNOWN'})}
confirmed=true
}finally{try{await parent.close()}catch{cleanupUnconfirmed=confirmed}}
return {activationSha256:sha(bytes),cleanupUnconfirmed}
}
function receipt(state:State,activationSha256:string,outcome:'committed'|'observed'){
return {event:'database-source-activation',outcome,activationSha256,status:state.status,
ordinal:state.generations.length,...state.generations.at(-1),generationAuthenticated:false,readyAuthorized:false,automaticReplay:false}
}
type SourceVerifier=ReturnType<typeof createProtectedDatabaseSourceBindingVerifier>
/** Keep revocation and generation changes fenced throughout one read-only SQL preflight. */
export async function withActiveSourcePreflight<T>(input:{directory:string,expectedSha256:string,sourcePin:z.infer<typeof sourcePin>,expectedSource:DatabaseSourceBindingBody['source'],tlsSpkiSha256:string,deadlineAt:number},
verifier:SourceVerifier,work:(guardedVerifier:SourceVerifier)=>Promise<T>){
const {directory,deadlineAt}=input
const expectedSha256=digest.parse(input.expectedSha256),tlsSpkiSha256=digest.parse(input.tlsSpkiSha256)
const pin=sourcePin.parse(input.sourcePin)
return withJournalLease(directory,async()=>{
async function readActive(){
assert(Date.now()<deadlineAt,'Activation preflight expired')
const active=await current(directory)
assert(active?.state.status==='active'&&active.activationSha256===expectedSha256,'Active activation digest changed')
assert(Date.now()<deadlineAt,'Activation preflight expired')
return active.state
}
const state=await readActive(),generation=state.generations.at(-1)!
const registry=await readProtectedDatabaseSourceBindingRegistry(pin,{deadlineAt})
assert(state.publicKeySpkiSha256===registry.publicKeySpkiSha256&&generation.tlsSpkiSha256===tlsSpkiSha256,'Activation key differs from preflight')
function assertGeneration(evidence:{installationId:string,sourceIdentityId:string,generation:string,ordinal:number,headSha256:string,bindingSha256:string}){
assert(evidence.installationId===state.installationId&&evidence.sourceIdentityId===state.sourceIdentityId&&
evidence.generation===generation.generation&&evidence.ordinal===state.generations.length&&
evidence.headSha256===generation.headSha256&&evidence.bindingSha256===generation.bindingSha256,'Activation differs from signed source generation')
}
assertGeneration({...registry.head,headSha256:registry.headSha256})
assert.equal(sourceBindingCanonical(registry.body.source),sourceBindingCanonical(input.expectedSource),'Activation source differs from reviewed configuration')
await readActive()
let sourceChecks=0
const guardedVerifier:SourceVerifier=Object.freeze({async verify(...args:Parameters<SourceVerifier['verify']>){
await readActive()
const evidence=await verifier.verify(...args)
assert(evidence.platformProtectionVerified&&evidence.operatorSignatureVerified&&evidence.currentHeadPinVerified&&evidence.genuineSourceFactsMatched,'Unprotected activation source evidence')
assertGeneration(evidence)
await readActive()
sourceChecks++
return evidence
}})
const result=await work(guardedVerifier)
assert.equal(sourceChecks,2,'Initial and terminal source evidence required')
await readActive()
return result
})
}
export async function sourceActivationMain(args:string[]){
const command=args[0], flags=command==='source-activate'?['directory','approval','approval-sha256']:
command==='source-revoke'?['directory','expected-sha256']:['directory']
assert(['source-activate','source-revoke','source-status'].includes(command)&&args.length===1+2*flags.length)
const values=new Map<string,string>()
for(let i=1;i<args.length;i+=2){
const name=args[i].slice(2)
assert(args[i].startsWith('--')&&flags.includes(name)&&!values.has(name)&&args[i+1]);values.set(name,args[i+1])
}
const directory=values.get('directory')!
return withJournalLease(directory,async()=>{
const before=await current(directory)
if(command==='source-status'){
assert(before,'Activation record absent')
return receipt(before.state,before.activationSha256,'observed')
}
if(command==='source-revoke'){
const expected=digest.parse(values.get('expected-sha256'))
assert(before?.activationSha256===expected&&before.state.status==='active','Active activation digest changed')
const revoked={...before.state,status:'revoked' as const}
const saved=await persist(directory,revoked)
return {...receipt(revoked,saved.activationSha256,'committed'),cleanupUnconfirmed:saved.cleanupUnconfirmed}
}
const request=approval.parse(parse(await readProtected(values.get('approval')!,16384,digest.parse(values.get('approval-sha256')))))
assert.equal(request.activationDirectory,directory,'Approval belongs to another activation directory')
assert.equal(before?.activationSha256??null,request.expectedActivationSha256,'Activation predecessor changed')
const registry=await readProtectedDatabaseSourceBindingRegistry(request.sourcePin,{deadlineAt:Date.now()+120000})
const previous=before?.state.generations??[]
assert.equal(registry.head.ordinal,previous.length+1,'Source ordinal changed')
assert.equal(registry.head.previousHeadSha256,previous.at(-1)?.headSha256??null,'Signed predecessor differs')
if(before){
assert.equal(registry.head.installationId,before.state.installationId)
assert.equal(registry.head.sourceIdentityId,before.state.sourceIdentityId)
assert.equal(registry.publicKeySpkiSha256,before.state.publicKeySpkiSha256)
}
assert(!previous.some(item=>item.generation.toLowerCase()===registry.head.generation.toLowerCase()||item.tlsSpkiSha256===request.tlsSpkiSha256),'Generation or TLS key was already used')
const next:State={format:'database-source-activation-v1',activationDirectory:directory,installationId:registry.head.installationId,
sourceIdentityId:registry.head.sourceIdentityId,publicKeySpkiSha256:registry.publicKeySpkiSha256,status:'active',
generations:[...previous,{generation:registry.head.generation,headSha256:registry.headSha256,
bindingSha256:registry.head.bindingSha256,tlsSpkiSha256:request.tlsSpkiSha256}]}
const saved=await persist(directory,next)
return {...receipt(next,saved.activationSha256,'committed'),cleanupUnconfirmed:saved.cleanupUnconfirmed}
})
}
@@ -0,0 +1,134 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import path from 'node:path'
import os from 'node:os'
import {createHash,generateKeyPairSync,randomUUID} from 'node:crypto'
import {createRequire} from 'node:module'
import {runInNewContext} from 'node:vm'
import {provisionDatabaseSourceBindingOffline} from '../database-source-binding-operator.ts'
import {readDatabaseSourceBindingRegistry,sourceBindingBytes,sourceBindingCanonical,type DatabaseSourceDeploymentPin} from '../../src/db/database-source-binding.ts'
import type * as Activation from './source-activation.mts'
const require=createRequire(import.meta.url),ts=require('typescript')
const source=await fs.readFile(new URL('./source-activation.mts',import.meta.url),'utf8')
const sha=(value:Buffer)=>createHash('sha256').update(value).digest('hex')
test('protected activation transition model preserves revocation and refuses concurrent, stale or reused generations',async()=>{
const root=await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(),'mm-source-activation-test-')))
try{
// Genuine issuer signatures and filesystem files. Windows models the
// protection/lease boundary and directory fsync used on the POSIX target.
const lock=path.join(root,'test-lease'),state=path.join(root,'source-activation.json')
const files={sha,protectedDirectory:async(directory:string)=>{assert.equal(directory,root)},
readProtected:async(filename:string,maximum:number,expected?:string)=>{
assert.equal(path.dirname(filename),root)
const bytes=await fs.readFile(filename);assert(bytes.length<=maximum)
if(expected)assert.equal(sha(bytes),expected)
return bytes
},async withJournalLease(directory:string,work:()=>Promise<unknown>){
assert.equal(directory,root);const handle=await fs.open(lock,'wx')
try{return await work()}finally{await handle.close();await fs.unlink(lock)}
}}
let parentFailure=''
const modelFs={...fs,open:async(filename:string,flags:number,mode?:number)=>filename===root?
{sync:async()=>{if(parentFailure==='sync')throw Error('fixture directory sync failure')},
close:async()=>{if(parentFailure==='close')throw Error('fixture directory close failure')}}:fs.open(filename,flags,mode)}
const imports:Record<string,unknown>={'node:fs/promises':modelFs,'./operator-files.mjs':files,
'./protected-source-registry.mjs':{readProtectedDatabaseSourceBindingRegistry:readDatabaseSourceBindingRegistry},
'../../src/db/database-source-binding.js':{sourceBindingBytes,sourceBindingCanonical}}
const load=()=>{
const exports={}
const compiled=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText
runInNewContext(compiled,{exports,require:(name:string)=>imports[name]??require(name),Buffer,TextDecoder})
return exports as typeof Activation
}
const {withActiveSourcePreflight}=load()
let {sourceActivationMain:run}=load()
const privateKeyFile=path.join(root,'issuer.pem'),key=generateKeyPairSync('ed25519').privateKey
await fs.writeFile(privateKeyFile,key.export({format:'pem',type:'pkcs8'}),{mode:0o600})
const installationId=randomUUID(),sourceIdentityId=randomUUID()
const issue=async(previousPin?:DatabaseSourceDeploymentPin,generation=randomUUID())=>{
return (await provisionDatabaseSourceBindingOffline({privateKeyFile,previousPin,outputDirectory:path.join(root,randomUUID()),
request:{installationId,sourceIdentityId,generation,source:{systemIdentifier:'123',databaseOid:16384,databaseName:'synthetic',
sessionUser:'publisher',currentUser:'publisher',inRecovery:false,serverVersionNum:180004}}})).deploymentPin
}
const status=['source-status','--directory',root]
const revoke=(expected:string)=>['source-revoke','--directory',root,'--expected-sha256',expected]
const activate=async(pin:DatabaseSourceDeploymentPin,tlsSpkiSha256:string,expectedActivationSha256:string|null,activationDirectory=root)=>{
const bytes=sourceBindingBytes({format:'database-source-activation-approval-v1',activationDirectory,sourcePin:pin,tlsSpkiSha256,expectedActivationSha256})
const file=path.join(root,randomUUID()+'.json');await fs.writeFile(file,bytes,{mode:0o600,flag:'wx'})
return ['source-activate','--directory',root,'--approval',file,'--approval-sha256',sha(bytes)]
}
const g1=await issue(undefined,'5ac5e8bd-0eb2-498b-901a-dd2010cd4b31'),g2=await issue(g1),g3=await issue(g2)
await assert.rejects(run(await activate(g1,'1'.repeat(64),null,path.join(root,'other'))),/another activation directory/)
const start=await activate(g1,'1'.repeat(64),null)
const races=await Promise.allSettled([run(start),run(start)])
assert.deepEqual(races.map(value=>value.status).sort(),['fulfilled','rejected'],races.map(value=>value.status==='rejected'?String(value.reason):value.status).join(', '))
const first=await run(status)
assert.equal(first.ordinal,1);assert.equal(first.headSha256,g1.currentHeadSha256)
assert.equal(first.generationAuthenticated,false)
const registry=await readDatabaseSourceBindingRegistry(g1)
const evidence={format:'database-source-binding-evidence-v1' as const,
installationId:registry.head.installationId,sourceIdentityId:registry.head.sourceIdentityId,generation:registry.head.generation,
ordinal:registry.head.ordinal,bindingSha256:registry.head.bindingSha256,headSha256:registry.headSha256,
operatorSignatureVerified:true as const,currentHeadPinVerified:true as const,
genuineSourceFactsMatched:true as const,platformProtectionVerified:true as const,generationAuthenticated:false as const,
allWriterDrainAuthenticated:false as const,readyAuthorized:false as const}
// The actual preflight exercises the checked-out PostgreSQL client. The guard fixture models its source verifier.
type Verifier=Parameters<typeof withActiveSourcePreflight>[1]
const verifierArgs=[] as unknown as Parameters<Verifier['verify']>
const preflight={directory:root,sourcePin:g1,expectedSource:registry.body.source,expectedSha256:first.activationSha256,tlsSpkiSha256:'1'.repeat(64),deadlineAt:Date.now()+30000}
const refuseConnect=()=>{throw Error('Reached SQL connection before activation was trusted')}
const verifier={verify:async()=>evidence}
await assert.rejects(withActiveSourcePreflight({...preflight,expectedSha256:'0'.repeat(64)},verifier,refuseConnect),/activation digest changed/)
await assert.rejects(withActiveSourcePreflight({...preflight,tlsSpkiSha256:'2'.repeat(64)},verifier,refuseConnect),/Activation key differs/)
await assert.rejects(withActiveSourcePreflight({...preflight,sourcePin:g2},verifier,refuseConnect),/differs from signed source generation/)
await assert.rejects(withActiveSourcePreflight({...preflight,expectedSource:{...preflight.expectedSource,currentUser:'different'}},verifier,refuseConnect),/differs from reviewed configuration/)
let verifications=0
const result=await withActiveSourcePreflight(preflight,{verify:async()=>{verifications++;return evidence}},async guarded=>{
assert.equal(await guarded.verify(...verifierArgs),evidence)
await assert.rejects(run(revoke(first.activationSha256)),{code:'EEXIST'})
assert.equal(await guarded.verify(...verifierArgs),evidence)
return 'matched'
})
assert.equal(result,'matched');assert.equal(verifications,2)
await assert.rejects(fs.access(lock),{code:'ENOENT'})
await assert.rejects(withActiveSourcePreflight(preflight,verifier,async guarded=>{
await guarded.verify(...verifierArgs)
}),/Initial and terminal source evidence required/)
let reads=0
await assert.rejects(withActiveSourcePreflight(preflight,{verify:async()=>++reads===1?evidence:{...evidence,bindingSha256:'0'.repeat(64)}},async guarded=>{
await guarded.verify(...verifierArgs)
await guarded.verify(...verifierArgs)
}),/differs from signed source generation/)
await fs.writeFile(state+'.pending','interrupted',{flag:'wx'})
await assert.rejects(withActiveSourcePreflight(preflight,verifier,refuseConnect),/Unresolved activation transition/)
await fs.unlink(state+'.pending')
await assert.rejects(run(start),/predecessor changed/)
const revoked=await run(revoke(first.activationSha256))
await assert.rejects(withActiveSourcePreflight({...preflight,expectedSha256:revoked.activationSha256},verifier,refuseConnect),/activation digest changed/)
run=load().sourceActivationMain
assert.equal((await run(status)).status,'revoked')
await assert.rejects(run(revoke(first.activationSha256)),/digest changed/)
await assert.rejects(run(await activate(g3,'3'.repeat(64),revoked.activationSha256)),/ordinal changed/)
const second=await run(await activate(g2,'2'.repeat(64),revoked.activationSha256))
assert.equal(second.ordinal,2);assert.equal(second.status,'active')
await assert.rejects(run(await activate(g3,'1'.repeat(64),second.activationSha256)),/already used/)
const repeatedGeneration=await issue(g2,first.generation!.toUpperCase())
await assert.rejects(run(await activate(repeatedGeneration,'3'.repeat(64),second.activationSha256)),/already used/)
await assert.rejects(run(await activate({...g3,currentHeadSha256:'0'.repeat(64)},'3'.repeat(64),second.activationSha256)))
assert.equal((await run(status)).activationSha256,second.activationSha256)
parentFailure='sync'
await assert.rejects(run(revoke(second.activationSha256)),{code:'SOURCE_ACTIVATION_TERMINAL_UNKNOWN'})
parentFailure='close'
const observed=await run(status)
assert.equal(observed.status,'revoked')
const third=await run(await activate(g3,'3'.repeat(64),observed.activationSha256))
assert.equal(third.outcome,'committed')
assert('cleanupUnconfirmed' in third && third.cleanupUnconfirmed===true)
await fs.writeFile(state+'.pending','interrupted',{flag:'wx'})
await assert.rejects(run(status),/Unresolved activation transition/)
await assert.rejects(run(revoke(third.activationSha256)),/Unresolved activation transition/)
}finally{await fs.rm(root,{recursive:true,force:true})}
})
+21 -5
View File
@@ -12,10 +12,10 @@ const reviewed = (source: string, extra: Partial<MigrationReview> = {}) => {
const bytes = Buffer.from(source), file = "9000_fixture.sql"
return () => analyzeMigration(file, bytes, { file, sha256: createHash("sha256").update(bytes).digest("hex"), transaction: "owner-transaction", selfMarker: false, opaqueBodies: 0, bodyReview: [], effects: ["Explicit inert fixture review"], ...extra })
}
await test("all 55 exact file pins and every top-level schema operation are inventoried", () => {
assert.equal(inventory.length, 55); assert.equal(MIGRATION_MANIFEST.length, 55)
await test("all 56 exact file pins and every top-level schema operation are inventoried", () => {
assert.equal(inventory.length, 56); assert.equal(MIGRATION_MANIFEST.length, 56)
assert.equal(inventory.filter(file => file.transaction === "owner-transaction").length, 17)
assert.equal(inventory.filter(file => file.transaction === "self-managed-single-transaction").length, 38)
assert.equal(inventory.filter(file => file.transaction === "self-managed-single-transaction").length, 39)
assert(inventory.every(file => file.operations.length === file.statementCount && file.effects.length > 0))
let tables = new Set<string>()
for (const file of inventory) {
@@ -32,8 +32,8 @@ await test("DO BEGIN and stored function BEGIN are never transaction boundaries"
assert.equal(endpoint.topLevelTransactionControls.length, 2); assert.equal(endpoint.opaqueBodies.length, 2)
assert.deepEqual(endpoint.opaqueBodies.map(body => body.kind), ["stored-function-body", "executed-do-body"])
})
await test("0035 through 0055 have exact atomic file-owned terminal markers", () => {
assert.deepEqual(inventory.filter(file => file.selfMarker).map(file => file.id), ["0035_server_probe_journal", "0036_diagnostic_cancellations", "0037_server_probe_storage_v2", "0038_server_poll_journal_and_writer_guards", "0039_server_poll_storage", "0040_mcp_routing_reservations", "0041_server_poll_terminal_archives", "0042_server_poll_settings_epoch", "0043_mcp_routing_forward_charges", "0044_mcp_routing_manual_inverse_charges", "0045_mcp_operation_replay_tombstones", "0046_server_poll_operation_retirement", "0047_server_poll_retirement_claims", "0048_mcp_ordinary_retirement_claims", "0049_mcp_ordinary_telemetry_retirement", "0050_server_probe_retired_sources", "0051_server_probe_retirement_claims", "0052_server_probe_retired_gc", "0053_server_probe_terminal_proofs", "0054_mcp_geoip_retirement", "0055_mcp_backup_schedule_retirement"])
await test("0035 through 0056 have exact atomic file-owned terminal markers", () => {
assert.deepEqual(inventory.filter(file => file.selfMarker).map(file => file.id), ["0035_server_probe_journal", "0036_diagnostic_cancellations", "0037_server_probe_storage_v2", "0038_server_poll_journal_and_writer_guards", "0039_server_poll_storage", "0040_mcp_routing_reservations", "0041_server_poll_terminal_archives", "0042_server_poll_settings_epoch", "0043_mcp_routing_forward_charges", "0044_mcp_routing_manual_inverse_charges", "0045_mcp_operation_replay_tombstones", "0046_server_poll_operation_retirement", "0047_server_poll_retirement_claims", "0048_mcp_ordinary_retirement_claims", "0049_mcp_ordinary_telemetry_retirement", "0050_server_probe_retired_sources", "0051_server_probe_retirement_claims", "0052_server_probe_retired_gc", "0053_server_probe_terminal_proofs", "0054_mcp_geoip_retirement", "0055_mcp_backup_schedule_retirement", "0056_mcp_manual_inverse_recovery_v8"])
for (const file of inventory.filter(file => file.selfMarker)) { assert.equal(file.selfMarker!.atomicWithAllFileEffects, true); assert.equal(file.selfMarker!.statement, file.statementCount - 2); assert.equal(file.currentOuterMarkerGap, false) }
assert.equal(inventory.filter(file => file.currentOuterMarkerGap).length, 34)
})
@@ -228,4 +228,20 @@ await test("0044 adds only a fixed manual inverse ledger and current-version int
assert(!pin.operations.some(operation => operation.command === "UPDATE" || operation.command === "DELETE"))
})
await test("0056 changes only live manual inverse recovery version guards", async () => {
const migration = inventory.find(file => file.id === "0056_mcp_manual_inverse_recovery_v8")!
assert.equal(migration.statementCount, 5)
assert.equal(migration.opaqueBodies.length, 2)
assert.equal(migration.selfMarker?.atomicWithAllFileEffects, true)
const oldSql = await readFile(new URL("../../drizzle/0044_mcp_routing_manual_inverse_charges.sql", import.meta.url), "utf8")
const newSql = await readFile(new URL("../../drizzle/0056_mcp_manual_inverse_recovery_v8.sql", import.meta.url), "utf8")
for (const name of ["mcp_routing_manual_inverse_charge_source", "mcp_scoped_recovery_manual_version"]) {
const definition = new RegExp(`CREATE(?: OR REPLACE)? FUNCTION public\\.${name}\\(\\)[\\s\\S]*?END \\$\\$;`)
const original = oldSql.match(definition)?.[0]?.replaceAll("\r\n", "\n")
assert.ok(original)
assert.equal(newSql.match(definition)?.[0]?.replaceAll("\r\n", "\n"), original.replace("CREATE FUNCTION", "CREATE OR REPLACE FUNCTION")
.replace("p.operation_id='operations.recover' AND p.operation_version=7", "p.operation_id='operations.recover' AND p.operation_version=8"))
}
})
console.log(`migration-manifest: ${cases} inert parser/inventory/baseline scenarios passed; ${inventory.length} exact files`)
+14
View File
@@ -854,6 +854,20 @@ const classifiedMigrationPins: readonly MigrationReview[] = [
"effects": [
"Replaces the ordinary claim guard function without changing backup settings, metadata or entries during migration."
]
},
{
"file": "0056_mcp_manual_inverse_recovery_v8.sql",
"sha256": "1dece1d9c5a9cf69e51336f35f868561dad9da6d21556ea31ef5b102634ed9ee",
"transaction": "self-managed-single-transaction",
"selfMarker": true,
"opaqueBodies": 2,
"bodyReview": [
"The manual inverse charge-source guard requires recovery version 8 and preserves its pending operation, source version, owner and profile checks.",
"The manual inverse intent guard requires recovery version 8 and preserves its advisory lock and exact running service intent checks."
],
"effects": [
"Replaces the two live version guard functions and preserves existing charges, intents, and plans."
]
}
]
@@ -53,7 +53,7 @@ try {
assert(baseline.sections.constraints.length > 100)
assert(baseline.sections.functions.length > 0)
assert.deepEqual(compareMigrationSchemas(baseline, await capture(reference.connection)), { equal: true, evidenceOnly: true, enrollmentAuthorized: false, sha256: baseline.sha256 })
cases.push("all36 pinned migrations produce repeatable complete PG18 catalog evidence on an actual checked-out read-only backend")
cases.push(`all ${inventory.length} pinned migrations produce repeatable complete PG18 catalog evidence on an actual checked-out read-only backend`)
} finally { await reference.connection.end() }
const independent = await database("independent")
try {
+3 -3
View File
@@ -5,7 +5,7 @@ import type { ConnectionRow, McpPrincipal } from "./store.js"
import type { RosSnapshot, RosStep } from "./resources.js"
export interface ActionContext {
connection: ConnectionRow; principal: McpPrincipal; signal?: AbortSignal
connection: ConnectionRow; principal: McpPrincipal; signal?: AbortSignal; logsFollowMaintenance?: AbortSignal
/** Process-internal persistent controller identity; never parsed from tool JSON. */
persistentRoutingProfile?: { id: string; version: number }
/** Available only during target resolution, never retained by the read/prepare handler. */
@@ -26,11 +26,11 @@ export interface ChangeEvidence {
requiresHuman?: boolean;
verificationScope?: "configuration" | "acknowledgement";
validUntil?: string;
observationChecks?: Array<{ id: string; srcServerId: number; dstServerId: number; srcInterface: string; family: string; table: string; fingerprint: string; validUntil: string }>;
observationChecks?: Array<{ id: string; srcServerId: number; dstServerId: number; srcInterface: string; dstInterface?: string; metric?: "pingAt" | "bandwidthAt"; family: string; table: string; fingerprint: string; validUntil: string }>;
ospfOwnerGraph?: { family: "ipv4" | "ipv6"; sourceSecretProofs: Array<{ serverId: number; proof: string }>; instances: Array<{ serverId: number; fingerprint: string }> };
managementChannels?: Array<{ channelId: string; serverId: number; expiresAt: string }>;
recovery?: { step: ChangeStep; expected: unknown; validUntil: string; authorityProfileId: string };
healthChecks?: Array<{ serverId: number; resource: string; id: string; field: "active" | "resolved" | "running" | "state" | "bfd-up"; phase?: "after"; identity?: Record<string, string> }>;
healthChecks?: Array<{ serverId: number; resource: string; id: string; field: "active" | "resolved" | "running" | "state" | "bfd-up"; phase?: "after"; identity?: Record<string, string>; nextHopInterface?: { name: string; id: string } }>;
warnings: string[];
}
export interface McpAction {
+97 -20
View File
@@ -9,15 +9,17 @@ import { registerIpsecExistingCertificateExportActions } from "./ipsec-existing-
import { registerIpsecUserUpdateActions } from "./ipsec-user-update-actions.js"
import { prepareIpsecNativeWrite } from "./ipsec-native-write-actions.js"
import { isIP } from "node:net"
import { setTimeout as sleep } from "node:timers/promises"
import { logsFollowCancellation } from "./logs-follow-cancellation.js"
import { z } from "zod"
import { logFiltersSchema, logSettingsPatchSchema } from "@mmapp/contracts"
import { appUserCreateSchema, appUserUpdateSchema, appUserRoleSchema, sectionPermSchema, serverPermSchema, userBindingCreateSchema } from "@mmapp/contracts/users"
import { appUserCreateSchema, appUserUpdateSchema, appUserRoleSchema, interfaceCatalogStateSchema, sectionPermSchema, serverPermSchema, userBindingCreateSchema } from "@mmapp/contracts/users"
import type { McpOperationDescriptor } from "@mmapp/contracts/mcp"
import { hasPermission } from "../../lib/permissions.js"
import { assertOperationReplayScope, findOperationReplayTombstone, operationHistoryExpired } from "./operation-replay-tombstones.js"
import { pool } from "../../db/index.js"
import { getServerReadById } from "../servers/service/servers-service.js"
import { listUsers, getUserById, listInterfaceCatalog, getInterfaceCatalogState } from "../users/service/users-service.js"
import { listUsers, getUserById, getInterfaceCatalogState, projectLegacyInterfaceCatalog, validateUserBindingCreate, UsersServiceError } from "../users/service/users-service.js"
import { getLogSettings } from "../../services/router-logs-collector.js"
import { readLogPage, readLogSummary, readLogContext, readLogEntry } from "../../services/router-logs-query.js"
import { logWhere } from "../../services/router-logs-model.js"
@@ -29,7 +31,7 @@ import { readLogStream } from "../../services/router-logs-query.js"
import { subscribeLogDevice } from "../../services/router-logs-collector.js"
import { rosResources, readRosSnapshot, resourceById, routerClient, validateRosStep, splitOspfPassiveUpdate } from "./resources.js"
import { registerMcpOperation, listMcpOperations } from "./registry.js"
import { accessGeneration, decryptSecret, encryptSecret, fingerprint, McpError, redact } from "./security.js"
import { accessGeneration, decryptSecret, encryptSecret, fingerprint, MAX_MCP_PUBLIC_ARRAY_ITEMS, MAX_MCP_PUBLIC_STRING_LENGTH, McpError, redact } from "./security.js"
import { validatePrincipal } from "./store.js"
import type { McpAction, ActionContext, ChangeEvidence } from "./action-types.js"
import { registerRoutingActions } from "./routing-actions.js"
@@ -77,7 +79,7 @@ import { prepareGreNativeChange, registerGreNativeServices } from "./gre-native-
import { registerVpnCatalogActions } from "./vpn-catalog-actions.js"
import { registerEvoBgpActions } from "./evobgp-actions.js"
import { registerRoutingProfileActions } from "./routing-profiles.js"
import { registerRoutingControls } from "./routing-controls.js"
import { readRpfDependencies, registerRoutingControls } from "./routing-controls.js"
import { registerLifecycleActions } from "./lifecycle-actions.js"
import { registerRecoveryActions } from "./recovery-actions.js"
import { registerPathActions } from "./path-verification.js"
@@ -104,6 +106,7 @@ import { registerRecursiveRecovery } from "./recursive-recovery.js"
import { registerNetworkHostActions } from "./network-host-actions.js"
import { registerBgpFilterBindingActions } from "./bgp-filter-binding-actions.js"
import { registerBgpCommunityPolicyActions } from "./bgp-community-policy-actions.js"
import { registerBridgeVlanActions, prepareGenericBridgeVlanChange } from "./bridge-vlan-actions.js"
const serverId = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
const rowId = z.string().trim().min(1).max(200)
@@ -127,6 +130,7 @@ registerRecursiveRecovery(add)
registerNetworkHostActions(add)
registerBgpFilterBindingActions(add)
registerBgpCommunityPolicyActions(add)
registerBridgeVlanActions(add)
registerTrafficHistoryActions(add)
registerSidebarCountsActions(add)
registerLogArtifactActions(add)
@@ -152,9 +156,9 @@ for (const resource of rosResources) {
? z.object({ serverId, fields: resource.fields.default({}), ...(resource.id === "vrf" ? { productionAdmission: z.literal(true).optional() } : {}) }).strict()
: z.object({ serverId, id: rowId.optional(), fields: resource.fields.default({}), ...(resource.id === "vrf" ? { productionAdmission: z.literal(true).optional() } : {}) }).strict()
const delegated = resource.id === "wireguard" && action !== "create" || resource.id === "wireguard_peer" || resource.id === "gre" && action !== "create" || resource.id === "ipsec_peer" && action !== "create" || resource.id === "certificate" && action === "delete" || resource.id === "container" && action === "delete"
const previousVersion = action === "create" ? (["ospf_template", "wireguard_peer", "wireguard"].includes(resource.id) ? 3 : 2) : resource.id === "ipsec_peer" || resource.id === "certificate" && action === "delete" ? 2 : resource.id === "wireguard" && action === "update" ? 3 : resource.id === "ospf_template" && action !== "delete" || resource.id === "wireguard_peer" || resource.id === "wireguard" ? 2 : 1
const previousVersion = action === "create" ? (["ospf_template", "wireguard_peer", "wireguard"].includes(resource.id) ? 3 : 2) : resource.id === "ipsec_peer" || resource.id === "certificate" && action === "delete" ? 2 : resource.id === "wireguard" && action === "update" ? 3 : resource.id === "ospf_template" && action !== "delete" || resource.id === "wireguard_peer" || resource.id === "wireguard" || resource.id === "ip_settings" ? 2 : 1
add(`ros.${resource.id}.${action}`, `mm_${resource.id}_${action}_plan`, `${resource.title}: ${action}`, resource.permission.split(":")[1], resource.id === "certificate" && action === "update" ? ["mm:certificates:write", "mm:network:write"] : resource.permission + ":write", "write", "servers", schema, {
version: resource.id === "ipsec_peer" ? (action === "create" ? 5 : 3) : resource.id === "certificate" ? (action === "update" ? 4 : 3) : previousVersion + (delegated ? 0 : 1) + (resource.id === "ospf_template" ? 1 : 0) + (resource.id === "vrf" ? 1 : 0),
version: resource.id === "ipsec_peer" ? (action === "create" ? 5 : 3) : resource.id === "certificate" ? (action === "update" ? 4 : 3) : previousVersion + (delegated ? 0 : 1) + (resource.id === "ospf_template" ? 1 : 0) + (resource.id === "vrf" ? (action === "delete" ? 3 : 2) : 0) + (["bridge", "bridge_port", "bridge_vlan"].includes(resource.id) ? 1 : 0),
prepare: async (input, context) => {
if (resource.id === "vrf" && input.productionAdmission !== true) throw new McpError("UNSUPPORTED_CAPABILITY", "VRF требует явного productionAdmission:true для проверенного native executor; raw план недоступен", 409)
const sid = Number(input.serverId), id = input.id as string | undefined
@@ -191,6 +195,8 @@ for (const resource of rosResources) {
const references: ChangeEvidence["objects"] = [{ kind: resource.id, id: id ?? String(fields.name ?? "new"), serverId: sid }]
if (resource.id.endsWith("settings")) references.push({ kind: resource.id, id: "singleton", serverId: sid })
const snapshots = [before]
const rpf = resource.id === "ip_settings" && fields["rp-filter"] ? await readRpfDependencies(sid, fields["rp-filter"], before.sourceProof) : undefined
if (rpf) snapshots.push(...rpf.snapshots)
if (["ospf_instance", "ospf_area", "ospf_template", "bfd_configuration", "bgp_instance", "bgp_connection", "vrf"].includes(resource.id)) await addRoutingDependencies(sid, resource.id, action, current, fields, snapshots, references)
if (["ipv4_route", "ipv6_route", "routing_rule", "bgp_connection"].includes(resource.id)) {
const tables = await readRosSnapshot(sid, "routing_table")
@@ -250,13 +256,24 @@ for (const resource of rosResources) {
if (dependent) throw new McpError("STATE_CONFLICT", "Объект используется: сначала подготовьте явное изменение зависимостей", 409)
}
}
const linkedVlan = await prepareGenericBridgeVlanChange({ serverId: sid, resource: resource.id, action, id, fields }, context)
if (linkedVlan) {
const captured = new Map(snapshots.map(snapshot => [snapshot.resource, snapshot]))
for (const snapshot of linkedVlan.snapshots) {
const previous = captured.get(snapshot.resource)
if (previous && (previous.fingerprint !== snapshot.fingerprint || previous.sourceProof !== snapshot.sourceProof)) throw new McpError("STATE_CONFLICT", "Зависимости VLAN изменились во время подготовки", 409)
captured.set(snapshot.resource, snapshot)
}
const objects = new Map([...references, ...linkedVlan.objects].map(object => [`${object.serverId}/${object.kind}/${object.id}`, object]))
return { ...linkedVlan, snapshots: [...captured.values()], objects: [...objects.values()] }
}
const placeBeforeId = resource.id === "vrf" && action === "create" ? before.rows[0]?.[".id"] : undefined
if (resource.id === "vrf" && action === "create" && typeof placeBeforeId !== "string") throw new McpError("STATE_CONFLICT", "Не подтверждён порядок существующих VRF", 409)
return { target: { scope: "servers", serverIds: [sid] }, objects: references, snapshots,
...(resource.id === "vrf" && input.productionAdmission === true ? { vrfProductionAdmission: true as const } : {}),
steps: splitOspfPassiveUpdate({ serverId: sid, resource: resource.id, action, id, fields, ...(typeof placeBeforeId === "string" ? { placeBeforeId } : {}) }).map(step => ({ kind: "ros" as const, step })),
requiresHuman: true,
warnings: ["Нативное изменение может затронуть управление устройством и требует подтверждения человеком.", "Автоматическое каскадное изменение зависимостей не выполняется.", ...(placeBeforeId ? [`VRF создаётся перед записью ${placeBeforeId}; порядок определяет принадлежность интерфейсов.`] : [])],
warnings: ["Нативное изменение может затронуть управление устройством и требует подтверждения человеком.", "Автоматическое каскадное изменение зависимостей не выполняется.", ...(rpf?.warnings ?? []), ...(resource.id === "vrf" && action === "delete" ? ["При удалении VRF RouterOS может удалить автоматический IPv6 link-local и его connected-маршрут пустого bridge; статические адреса сохраняются."] : []), ...(placeBeforeId ? [`VRF создаётся перед записью ${placeBeforeId}; порядок определяет принадлежность интерфейсов.`] : [])],
}
},
})
@@ -268,7 +285,7 @@ for (const definition of [
{ id: "firewall.update", tool: "mm_firewall_rule_update_plan", title: "План изменения правила firewall", resources: ["ip_filter", "ip_nat", "ip_mangle", "ip_raw", "ipv6_filter", "ipv6_nat", "ipv6_mangle", "ipv6_raw"] },
] as const) {
add(definition.id, definition.tool, definition.title, "network", "mm:network:write", "write", "servers", z.object({ serverId, resource: z.enum(definition.resources), id: rowId, fields: z.record(z.string().max(100), z.union([z.string().max(1000), z.number().finite(), z.boolean()])) }).strict(), {
version: definition.id === "interfaces.update" ? 4 : 2,
version: definition.id === "interfaces.update" ? 5 : 2,
prepare: async (input, context) => {
const resource = resourceById(String(input.resource)), fields = resource.fields.safeParse(input.fields)
if (!fields.success) throw new McpError("INVALID_INPUT", "Поля не соответствуют типизированной схеме выбранного объекта", 400)
@@ -301,11 +318,33 @@ add("users.list", "mm_users_list", "Пользователи", "users", "mm:user
add("users.get", "mm_users_get", "Пользователь по ID", "users", "mm:users:read", "read", "global", z.object({ id: rowId }).strict(), {
read: async (input, context) => ({ user: scopedUser(await getUserById(String(input.id)), context) }),
})
const invalidInterfaceCatalog = () => new McpError("STATE_UNAVAILABLE", "Не подтверждён контекст каталога интерфейсов.", 502)
async function readMcpInterfaceCatalog(serverId: number) {
const state = await getInterfaceCatalogState(serverId).catch(error => {
if (error instanceof UsersServiceError) {
if (error.status === 404) throw new McpError("NOT_FOUND", "Сервер не найден", 404)
if (error.status === 502) throw invalidInterfaceCatalog()
}
throw error
})
const parsed = interfaceCatalogStateSchema.safeParse(state)
if (!parsed.success || parsed.data.serverId !== serverId) throw invalidInterfaceCatalog()
return parsed.data
}
function catalogueExceedsPublicLimits(value: unknown): boolean {
if (typeof value === "string") return value.length > MAX_MCP_PUBLIC_STRING_LENGTH
if (Array.isArray(value)) return value.length > MAX_MCP_PUBLIC_ARRAY_ITEMS || value.some(catalogueExceedsPublicLimits)
return value !== null && typeof value === "object" && Object.values(value).some(catalogueExceedsPublicLimits)
}
function intactInterfaceCatalog<T>(result: T): T {
if (catalogueExceedsPublicLimits(result)) throw new McpError("BUDGET_EXCEEDED", "Каталог интерфейсов превышает предел ответа MCP.", 413)
return result
}
add("users.interfaces.read", "mm_user_interfaces_list", "Каталог интерфейсов для привязок пользователей", "users", "mm:users:read", "read", "servers", z.object({ serverId }).strict(), {
read: async input => ({ interfaces: await listInterfaceCatalog(Number(input.serverId)) }),
read: async input => intactInterfaceCatalog({ interfaces: projectLegacyInterfaceCatalog(await readMcpInterfaceCatalog(Number(input.serverId))) }),
})
add("users.interfaces.state", "mm_user_interfaces_state_get", "Состояние каталога интерфейсов для привязок", "users", "mm:users:read", "read", "servers", z.object({ serverId }).strict(), {
read: async input => getInterfaceCatalogState(Number(input.serverId)),
read: async input => intactInterfaceCatalog(await readMcpInterfaceCatalog(Number(input.serverId))),
})
for (const action of ["create", "update", "delete", "binding_add", "binding_remove"] as const) {
const schema = action === "create" ? z.object({ user: appUserCreateSchema.omit({ role: true, sections: true, servers: true }).strict() }).strict()
@@ -322,6 +361,21 @@ for (const action of ["create", "update", "delete", "binding_add", "binding_remo
const ids = [...new Set([...(user?.servers ?? []).map(server => Number(server.serverId)), ...(binding?.serverId ? [binding.serverId] : []),
...(before?.bindings ?? []).map(binding => binding.serverId), ...(before?.servers ?? []).map(server => Number(server.serverId))])]
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Сервер не разрешён")
if (action === "binding_remove" && !before?.bindings.some(binding => binding.id === input.bindingId)) {
throw new McpError("NOT_FOUND", "Привязка не найдена", 404)
}
if (action === "binding_add") {
try {
await validateUserBindingCreate(String(input.id), userBindingCreateSchema.parse(input.binding), { signal: context.signal })
} catch (error) {
if (error instanceof UsersServiceError) {
if (error.status === 409) throw new McpError("STATE_CONFLICT", error.message, 409)
if (error.status === 400) throw new McpError("INVALID_INPUT", error.message, 400)
if (error.status === 404) throw new McpError("NOT_FOUND", error.message, 404)
}
throw error
}
}
return { target: { scope: "global" }, objects: [{ kind: "user", id: String(input.id ?? "new") }, ...ids.map(id => ({ kind: "server", id: String(id), serverId: id }))], snapshots: [], before,
steps: [{ kind: "service", service: `users.${action}`, input }], warnings: ["Изменение /users не меняет JWT-права портала."] }
},
@@ -350,16 +404,17 @@ function logFilters(input: Record<string, unknown>, context: ActionContext) {
}
add("logs.query", "mm_logs_query", "История логов", "logs", "mm:servers:read", "read", "servers", logInput, {
read: async (input, context) => {
const filters = logFilters(input, context), generation = await accessGeneration()
const generation = await accessGeneration()
let filters: ReturnType<typeof logFilters>
let cursor: string | undefined, watermark: string | undefined
if (input.cursor) {
try {
const state = JSON.parse(await decryptSecret(String(input.cursor))) as { generation: string; scope: string; cursor: string; watermark: string; from: string }
if (input.from === undefined) filters.from = state.from
filters = logFilters({ ...input, from: input.from ?? state.from }, context)
if (state.generation !== generation || state.scope !== fingerprint({ filters, connection: context.connection.id })) throw new Error("cursor")
cursor = state.cursor; watermark = state.watermark
} catch { throw new McpError("RESET_REQUIRED", "Курсор устарел или относится к другим фильтрам", 409) }
}
} else filters = logFilters(input, context)
const result = await readLogPage(filters, { cursor, watermark, limit: Number(input.limit) })
return { ...result, nextCursor: result.nextCursor ? await encryptSecret(JSON.stringify({ generation, scope: fingerprint({ filters, connection: context.connection.id }), cursor: result.nextCursor, watermark: result.watermark, from: filters.from })) : null }
},
@@ -374,30 +429,41 @@ for (const context of [false, true]) add(context ? "logs.context" : "logs.get",
return context ? readLogContext(String(input.id)) : entry
},
})
add("logs.follow", "mm_logs_follow", "Новые события", "logs", "mm:servers:read", "read", "servers", logInput.safeExtend({ serverId, cursor: z.string().max(8192).optional(), waitSeconds: z.number().int().min(0).max(30).default(30) }), {
add("logs.follow", "mm_logs_follow", "Новые события", "logs", "mm:servers:read", "read", "servers", logInput.safeExtend({ serverId, limit: logInput.shape.limit.default(200), cursor: z.string().max(8192).optional(), waitSeconds: z.number().int().min(0).max(30).default(30) }), {
read: async (input, context) => {
const maintenance = context.logsFollowMaintenance
const signal = context.signal
const waitSignal = maintenance && signal ? AbortSignal.any([maintenance, signal]) : maintenance ?? signal
const assertActive = () => { const failure = logsFollowCancellation(maintenance, signal); if (failure) throw failure }
assertActive()
const { server } = await routerClient(Number(input.serverId)); void server
assertActive()
const release = subscribeLogDevice(Number(input.serverId)), until = Date.now() + Number(input.waitSeconds) * 1000
try {
const filters = logFilters(input, context), generation = await accessGeneration()
const generation = await accessGeneration()
let filters: ReturnType<typeof logFilters>
let cursor = "0"
if (input.cursor) {
try {
const saved = JSON.parse(await decryptSecret(String(input.cursor)))
filters.from = saved.from
filters = logFilters({ ...input, from: input.from ?? saved.from }, context)
if (saved.kind !== "follow" || saved.generation !== generation || saved.scope !== fingerprint({ filters, connection: context.connection.id }) || !/^\d{1,20}$/.test(saved.cursor)) throw new Error("cursor")
cursor = saved.cursor
} catch { throw new McpError("RESET_REQUIRED", "Курсор follow устарел или относится к другим фильтрам", 409) }
} else {
filters = logFilters(input, context)
cursor = (await pool.query("SELECT COALESCE(max(id),0)::text AS cursor FROM router_log_entries WHERE server_id=$1", [input.serverId])).rows[0].cursor
}
while (true) {
assertActive()
const current = await validatePrincipal(context.principal)
assertActive()
if (current.policy.version !== context.connection.policy.version) throw new McpError("RESET_REQUIRED", "Права или область доступа изменились; начните новое чтение", 409)
const batch = await readLogStream(filters, cursor)
const batch = await readLogStream(filters, cursor, Number(input.limit))
assertActive()
cursor = batch.cursor
if (batch.entries.length || !batch.enabled || Date.now() >= until || context.signal?.aborted) return { ...batch, cursor: await encryptSecret(JSON.stringify({ kind: "follow", generation, scope: fingerprint({ filters, connection: context.connection.id }), cursor, from: filters.from })) }
await new Promise(resolve => setTimeout(resolve, 1000))
if (batch.entries.length || !batch.enabled || Date.now() >= until) return { ...batch, cursor: await encryptSecret(JSON.stringify({ kind: "follow", generation, scope: fingerprint({ filters, connection: context.connection.id }), cursor, from: filters.from })) }
await sleep(1000, undefined, { signal: waitSignal })
}
} finally { release() }
},
@@ -423,7 +489,18 @@ add("logs.settings.update", "mm_logs_settings_update_plan", "Изменение
prepare: async (input, context) => {
const ids = (await pool.query<{ id: number }>("SELECT id FROM servers")).rows.map(row => Number(row.id))
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Глобальная настройка сборщика затрагивает скрытые серверы")
return { target: { scope: "global" }, objects: ids.map(serverId => ({ kind: "server", id: String(serverId), serverId })), snapshots: [], before: await getLogSettings(), steps: [{ kind: "service", service: "logs.settings.update", input }], warnings: ["Отключение сбора и уменьшение retention влияют на последующую доступность истории."] }
const before = await getLogSettings()
const settings = logSettingsPatchSchema.parse(input.settings)
const warnings: string[] = []
if (before.enabled && settings.enabled === false) warnings.push("После применения сборщик прекратит получать новые события.")
if (settings.retentionDays !== undefined && settings.retentionDays < before.retentionDays) {
warnings.push(`Срок хранения сокращается с ${before.retentionDays} до ${settings.retentionDays} суток. При последующей очистке удаляются записи, полученные более ${settings.retentionDays} суток назад.`)
}
return {
target: { scope: "global" },
objects: ids.map(serverId => ({ kind: "server", id: String(serverId), serverId })),
snapshots: [], before, steps: [{ kind: "service", service: "logs.settings.update", input }], warnings,
}
},
})
@@ -17,6 +17,7 @@ try{
await pool.query("INSERT INTO backup_storage_settings(id) VALUES(1) ON CONFLICT(id) DO NOTHING")
await updateMcpSettings({enabled:true},actor)
app=await buildApp({startScheduler:false});await app.ready()
await (await import("../../test/mcp-users-bindings.js")).verifyMcpUserBindings(actor)
await (await import("./routing-community-context-acceptance.js")).acceptRouteCommunityContextMcp(actor)
await (await import("../../test/mcp-acme-test.js")).verifyMcpAcmeTest(actor)
await (await import("../../test/mcp-acme-settings.js")).verifyMcpAcmeSettings(actor)
@@ -0,0 +1,153 @@
import { z } from "zod"
import { isIP } from "node:net"
import { bridgeVlanConfigurationRequestSchema } from "@mmapp/contracts/bridge-vlan"
import { buildBridgeVlanConfiguration, bridgeVlanCurrentDesired, BridgeVlanConfigurationError, type BridgeVlanState } from "../../services/bridge-vlan-configuration.js"
import { readRosSnapshot, routerClient, rosSnapshotSourceProof, type RosSnapshot, type RosStep } from "./resources.js"
import { verifyBridgeManagementChannel } from "./management.js"
import { McpError } from "./security.js"
import { canonicalBridgeVlanValue } from "../../services/bridge-vlan-values.js"
import type { ActionContext, ChangeEvidence } from "./action-types.js"
import type { add } from "./actions.js"
const interfaceResources = ["ethernet", "bridge", "vlan", "gre", "wireguard", "vxlan"]
const resources = [...interfaceResources, "bridge_port", "bridge_vlan", "ipv4_address", "ipv6_address"]
export async function readBridgeVlanState(serverId: number, context: Pick<ActionContext, "signal">): Promise<{ state: BridgeVlanState; snapshots: RosSnapshot[] }> {
const snapshots: RosSnapshot[] = []
const active = async () => { if (context.signal?.aborted) throw new McpError("READ_CANCELLED", "Чтение конфигурации VLAN отменено", 409) }
// Bind every catalogue to the same configured device source. The executor
// rechecks these complete configuration snapshots before each native write.
for (const resource of resources) snapshots.push(await readRosSnapshot(serverId, resource, active, snapshots[0]?.sourceProof))
const rows = (resource: string) => snapshots.find(snapshot => snapshot.resource === resource)!.rows
const state: BridgeVlanState = {
bridges: rows("bridge"), ports: rows("bridge_port"), vlans: rows("bridge_vlan"),
interfaces: snapshots.filter(snapshot => interfaceResources.includes(snapshot.resource)).flatMap(snapshot => snapshot.rows.map(row => ({
resource: snapshot.resource, id: String(row[".id"]), name: String(row.name), disabled: row.disabled,
}))),
vlanInterfaces: rows("vlan"), ipv4Addresses: rows("ipv4_address"), ipv6Addresses: rows("ipv6_address"),
}
return { state, snapshots }
}
export async function prepareBridgeVlanConfiguration(raw: unknown, context: ActionContext): Promise<ChangeEvidence> {
const input = bridgeVlanConfigurationRequestSchema.parse(raw)
let management: Awaited<ReturnType<typeof verifyBridgeManagementChannel>> | undefined
try { management = await verifyBridgeManagementChannel(input.serverId) }
catch (error) {
// Only lack of a current alternate channel permits primary-path admission.
// Identity, transport and other verification failures stay fail-closed.
if (!(error instanceof McpError) || error.code !== "APPROVAL_REQUIRED") throw error
}
const { state, snapshots } = await readBridgeVlanState(input.serverId, context)
const primaryManagement = management === undefined
if (!management) {
const { server } = await routerClient(input.serverId)
if (!isIP(server.host)) throw new McpError("MANAGEMENT_UNSAFE", "Основной адрес управления должен быть буквальным IP, подтверждённым в конфигурации устройства", 409)
if (!snapshots[0]?.sourceProof || await rosSnapshotSourceProof(server) !== snapshots[0].sourceProof) throw new McpError("STATE_CONFLICT", "Источник основного управления изменился во время подготовки VLAN", 409)
const expiresAt = Math.min(Date.parse(context.connection.policy.expiresAt), context.connection.owner_valid_until.getTime(), Date.parse(context.principal.credentialExpiresAt))
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) throw new McpError("APPROVAL_REQUIRED", "Нет действующего допуска для основного канала управления", 409)
management = { channelId: "primary", serverId: input.serverId, host: server.host, expiresAt: new Date(expiresAt).toISOString() }
}
try {
const plan = buildBridgeVlanConfiguration({ ...input, state, management, now: Date.now() })
if (!plan.steps.length) throw new McpError("STATE_CONFLICT", "Конфигурация VLAN уже соответствует плану", 409)
return {
target: { scope: "servers", serverIds: [input.serverId] }, objects: plan.objects,
snapshots, steps: plan.steps.map(step => ({ kind: "ros" as const, step })),
requiresHuman: true, validUntil: management.expiresAt,
...(primaryManagement ? {} : { managementChannels: [{ channelId: management.channelId, serverId: management.serverId, expiresAt: management.expiresAt }] }),
warnings: ["Связанный план изменяет VLAN memberships и PVID в указанном порядке; включение filtering выполняется последним.",
"Каждая подтверждённая запись проверяется до следующей. При неизвестном результате дальнейшие записи останавливаются.",
primaryManagement ? "Используется основной адрес управления; независимость канала не подтверждена. Обязательно подтверждение человеком; каждый префикс плана сохраняет подтверждённый management-доступ."
: "Проверяется конфигурация и доступность зарегистрированного management-канала. Передача пользовательского трафика требует отдельной сетевой проверки."],
}
} catch (error) {
if (error instanceof BridgeVlanConfigurationError) throw new McpError(error.code, error.message, 409)
throw error
}
}
/** A legacy single-row tool must pass the same effective-state checks, but may
* not acquire authority to edit other resource kinds through this delegation. */
export async function prepareGenericBridgeVlanChange(step: RosStep, context: ActionContext): Promise<ChangeEvidence | undefined> {
const sensitive = step.resource === "bridge" ? ["pvid", "vlan-filtering", "frame-types", "ingress-filtering"] : step.resource === "bridge_port"
? ["bridge", "interface", "pvid", "ingress-filtering", "frame-types", "disabled"] : ["bridge", "vlan-ids", "tagged", "untagged", "disabled"]
if (!["bridge", "bridge_port", "bridge_vlan"].includes(step.resource) || step.resource === "bridge" && step.action !== "update"
|| step.action === "update" && !Object.keys(step.fields).some(key => sensitive.includes(key))) return undefined
const { state } = await readBridgeVlanState(step.serverId, context)
const rows = step.resource === "bridge" ? state.bridges : step.resource === "bridge_port" ? state.ports : state.vlans
const row = rows.find(item => item[".id"] === step.id)
const bridgeName = step.resource === "bridge" ? row?.name : step.fields.bridge ?? row?.bridge
const bridges = state.bridges.filter(item => item.name === bridgeName)
if (bridges.length !== 1 || step.resource !== "bridge" && row && row.bridge !== bridgeName) throw new McpError("STATE_CONFLICT", "Смена bridge требует отдельных явных планов удаления и добавления порта", 409)
const bridgeId = String(bridges[0][".id"])
try {
const desired = bridgeVlanCurrentDesired(state, bridgeId)
const bool = (value: unknown): boolean => {
if (value === "true" || value === true) return true
if (value === "false" || value === false) return false
throw new McpError("INVALID_INPUT", "Нужны явные параметры порта VLAN", 400)
}
const ref = (name: unknown) => {
const matches = state.interfaces.filter(iface => iface.name === name)
if (matches.length !== 1) throw new McpError("INVALID_INPUT", "Не подтверждён стабильный ID участника VLAN", 400)
return { resource: matches[0].resource, id: matches[0].id }
}
if (step.resource === "bridge") {
if (Object.keys(step.fields).some(key => !sensitive.includes(key))) throw new McpError("INVALID_INPUT", "Подготовьте свойства bridge и VLAN filtering отдельными планами", 400)
if (step.fields.pvid !== undefined) desired.pvid = Number(step.fields.pvid)
if (step.fields["vlan-filtering"] !== undefined) desired.filtering = bool(step.fields["vlan-filtering"])
if (step.fields["frame-types"] !== undefined) desired.frameTypes = bridgeVlanConfigurationRequestSchema.shape.desired.shape.frameTypes.parse(step.fields["frame-types"])
if (step.fields["ingress-filtering"] !== undefined) desired.ingressFiltering = bool(step.fields["ingress-filtering"])
} else if (step.resource === "bridge_port") {
const index = desired.ports.findIndex(port => port.id === step.id)
if (step.action === "delete") {
if (index < 0) throw new McpError("NOT_FOUND", "Статический порт не найден", 404)
desired.ports.splice(index, 1); desired.removePortIds.push(step.id!)
} else {
const effective = { ...row, ...step.fields }
const port = { ...(step.action === "update" ? { id: step.id } : {}), interface: ref(effective.interface), pvid: Number(effective.pvid),
ingressFiltering: bool(effective["ingress-filtering"]), frameTypes: effective["frame-types"], disabled: bool(effective.disabled),
...(effective.comment === undefined ? {} : { comment: effective.comment }) }
const parsed = bridgeVlanConfigurationRequestSchema.shape.desired.shape.ports.element.parse(port)
if (step.action === "create") desired.ports.push(parsed)
else { if (index < 0) throw new McpError("NOT_FOUND", "Статический порт не найден", 404); desired.ports[index] = parsed }
}
} else {
const index = desired.vlans.findIndex(vlan => vlan.id === step.id)
if (step.action === "delete") {
if (index < 0) throw new McpError("NOT_FOUND", "Статическая VLAN membership не найдена", 404)
desired.vlans.splice(index, 1); desired.removeVlanIds.push(step.id!)
} else {
const effective = { ...row, ...step.fields }
const members = (value: unknown) => value === undefined || value === "" ? [] : String(value).split(",").map(ref)
const vlan = { ...(step.action === "update" ? { id: step.id } : {}), vlanIds: effective["vlan-ids"], tagged: members(effective.tagged), untagged: members(effective.untagged),
disabled: effective.disabled === undefined && step.action === "create" ? false : bool(effective.disabled), ...(effective.comment === undefined ? {} : { comment: effective.comment }) }
const parsed = bridgeVlanConfigurationRequestSchema.shape.desired.shape.vlans.element.parse(vlan)
if (step.action === "create") desired.vlans.push(parsed)
else { if (index < 0) throw new McpError("NOT_FOUND", "Статическая VLAN membership не найдена", 404); desired.vlans[index] = parsed }
}
}
const evidence = await prepareBridgeVlanConfiguration({ serverId: step.serverId, bridgeId, desired }, context)
if (evidence.steps.length !== 1 || evidence.steps[0].kind !== "ros" || evidence.steps[0].step.resource !== step.resource || evidence.steps[0].step.action !== step.action || evidence.steps[0].step.id !== step.id) {
throw new McpError("APPROVAL_REQUIRED", "Для связанных изменений используйте явный план mm_bridge_vlan_configuration_plan с соответствующим разрешением", 409)
}
const native = evidence.steps[0].step
if (step.action === "update" && Object.entries(native.fields).some(([key, value]) => !Object.hasOwn(step.fields, key)
|| (step.resource === "bridge_vlan" ? canonicalBridgeVlanValue(key, value) !== canonicalBridgeVlanValue(key, step.fields[key]) : value !== step.fields[key]))) {
throw new McpError("STATE_CONFLICT", "Состояние VLAN изменилось во время подготовки; незапрошенные поля не будут перезаписаны", 409)
}
return evidence
} catch (error) {
if (error instanceof BridgeVlanConfigurationError) throw new McpError(error.code, error.message, 409)
throw error
}
}
export function registerBridgeVlanActions(register: typeof add) {
register("network.bridge-vlan.read", "mm_bridge_vlan_configuration_get", "Полная конфигурация bridge VLAN и стабильные ID участников", "network", "mm:network:read", "read", "servers", z.object({ serverId: z.number().int().positive() }).strict(), {
read: async (input, context) => ({ state: (await readBridgeVlanState(Number(input.serverId), context)).state }),
})
register("network.bridge-vlan.configure", "mm_bridge_vlan_configuration_plan", "Связанный план VLAN membership, PVID и bridge filtering", "network", "mm:network:write", "write", "servers", bridgeVlanConfigurationRequestSchema, {
prepare: prepareBridgeVlanConfiguration,
})
}
@@ -0,0 +1,21 @@
import assert from "node:assert/strict"
process.env.AUTH_REQUIRED = "true"
process.env.AUTH_JWT_SECRET = "bridge-vlan-owned-acceptance-secret"
process.env.AUTH_ISSUER = "https://auth.test.local"
const { pool, closePool } = await import("../../db/index.js")
const { applySqlMigrations } = await import("../../db/migrate.js")
const { updateMcpSettings } = await import("./store.js")
const { buildApp } = await import("../../index.js")
const actor = { id: "bridge-vlan-owner", email: "bridge@example.invalid", name: "Bridge VLAN owner", apps: ["mm"], permissions: ["mm:settings:admin", "mm:network:admin"], expiresAt: Date.now() + 3600000 }
let app: Awaited<ReturnType<typeof buildApp>> | undefined
try {
await applySqlMigrations(pool)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0, "Disposable empty database required")
await updateMcpSettings({ enabled: true }, actor)
app = await buildApp({ startScheduler: false }); await app.ready()
await (await import("../../test/mcp-bridge-vlan-configuration.js")).verifyMcpBridgeVlanConfiguration(actor, app)
} finally {
await (await import("../../services/scheduler.js")).stopSchedulerForDatabaseOperation()
await (await import("../../services/traffic-flow-ingest.js")).stopTrafficFlowListenerForDatabaseOperation()
await app?.close(); await closePool()
}
@@ -0,0 +1,76 @@
import assert from "node:assert/strict"
import test from "node:test"
import { configurationRows, resourceById, rosSnapshotVersion, type ConfigurationRow, type RosSnapshot, type RosStep } from "./resources.js"
import { createRosPrefix } from "./ros-prefix.js"
import { verifyRosStepSnapshots } from "./ros-step-verification.js"
import { fingerprint, McpError } from "./security.js"
const snapshot = (resource: string, raw: ConfigurationRow[]): RosSnapshot => {
const rows = configurationRows(resourceById(resource), raw)
return { serverId: 1, resource, rows, fingerprint: fingerprint(rows), sourceProof: "a".repeat(64), projectionVersion: rosSnapshotVersion(resource) }
}
const membership = { ".id": "*1", bridge: "br", "vlan-ids": "20-22", tagged: "ether2,ether3", dynamic: "false", disabled: "false" }
test("VLAN readback accepts equivalent VID/member sets and omitted empty fields, retaining exact native identity", () => {
const before = snapshot("bridge_vlan", [membership])
const after = snapshot("bridge_vlan", [{ ...membership, "vlan-ids": "22,20,21", tagged: "ether3,ether2" }])
const step: RosStep = { serverId: 1, resource: "bridge_vlan", action: "update", id: "*1", fields: { "vlan-ids": "20-22", tagged: "ether3,ether2", untagged: "", comment: "" } }
assert.equal(verifyRosStepSnapshots(step, {}, before, after), true)
assert.equal(verifyRosStepSnapshots(step, {}, before, snapshot("bridge_vlan", [{ ...membership, "vlan-ids": "20-23" }])), false)
assert.equal(verifyRosStepSnapshots(step, {}, before, snapshot("bridge_vlan", [{ ...membership, ".id": "*2" }])), false)
assert.throws(() => snapshot("bridge_vlan", [{ ...membership, "vlan-ids": "20,20-22" }]), /Недостоверная/)
})
test("PVID-generated rows may change while the sealed static VLAN configuration remains exact", async () => {
const port = { ".id": "*2", bridge: "br", interface: "ether2", pvid: "10", disabled: "false" }
const dynamic = { ".id": "*D", bridge: "br", "vlan-ids": "10", untagged: "ether2", dynamic: "true" }
const raw = new Map<string, ConfigurationRow[]>([["bridge_port", [port]], ["bridge_vlan", [membership, dynamic]]])
const initial = [...raw].map(([resource, rows]) => snapshot(resource, rows))
const read = async (_server: number, resource: string, active: () => Promise<unknown> = async () => {}) => { await active(); return snapshot(resource, raw.get(resource)!) }
const prefix = createRosPrefix(initial, read)
await prefix.check()
raw.set("bridge_port", [{ ...port, pvid: "20" }])
raw.set("bridge_vlan", [membership, { ...dynamic, ".id": "*E", "vlan-ids": "20" }])
const step: RosStep = { serverId: 1, resource: "bridge_port", action: "update", id: "*2", fields: { pvid: "20" } }
await prefix.advance(step, {}, await read(1, "bridge_port"))
await prefix.check()
raw.set("bridge_vlan", [{ ...membership, tagged: "ether2" }, dynamic])
await assert.rejects(prefix.check(), error => error instanceof McpError && error.code === "STATE_CONFLICT")
})
test("full VLAN ranges and long sparse VID sets stay representable in plans and inverse snapshots", () => {
const resource = resourceById("bridge_vlan")
const expanded = Array.from({ length: 4094 }, (_, index) => String(index + 1)).join(",")
const sparse = Array.from({ length: 2047 }, (_, index) => String(index * 2 + 1)).join(",")
assert.ok(sparse.length > 100)
for (const value of ["1-4094", sparse]) assert.equal(resource.fields.safeParse({ "vlan-ids": value }).success, true)
for (const value of ["0", "4095", "10-9", "10,10-12"]) assert.equal(resource.fields.safeParse({ "vlan-ids": value }).success, false)
const before = snapshot("bridge_vlan", [membership])
const after = snapshot("bridge_vlan", [{ ...membership, "vlan-ids": expanded }])
assert.equal(after.rows[0]["vlan-ids"], "1-4094", "Native expansion must not overflow the public inverse DTO")
assert.equal(verifyRosStepSnapshots({ serverId: 1, resource: "bridge_vlan", action: "update", id: "*1", fields: { "vlan-ids": "1-4094" } }, {}, before, after), true)
})
test("unknown dynamic flags are retained; only explicit RouterOS-generated VLAN rows are excluded", () => {
assert.equal(snapshot("bridge_vlan", [{ ...membership, dynamic: "unknown" }]).rows.length, 1)
for (const dynamic of [true, "true", "yes"]) assert.equal(snapshot("bridge_vlan", [{ ...membership, dynamic }]).rows.length, 0)
assert.equal(snapshot("bridge_vlan", [{ ...membership, dynamic: "false" }]).rows.length, 1)
})
test("old bridge CPU, port and VLAN projection contracts cannot execute after semantic projection changes", () => {
for (const resource of ["bridge", "bridge_port", "bridge_vlan"]) {
const current = snapshot(resource, resource === "bridge_vlan" ? [membership] : [{ ".id": "*2", bridge: "br", interface: "ether2", pvid: "10" }])
assert.equal(current.projectionVersion, 4)
assert.throws(() => createRosPrefix([{ ...current, projectionVersion: 3 }]), error => error instanceof McpError && error.code === "PLAN_EXPIRED")
}
})
test("CPU frame admission and MVRP configuration are sealed, with no MVRP write permission", () => {
const bridge = { ".id": "*1", name: "br", pvid: "10", "vlan-filtering": "true", "frame-types": "admit-all", "ingress-filtering": "true", mvrp: "false", running: "true" }
const original = snapshot("bridge", [bridge])
for (const field of ["frame-types", "ingress-filtering", "mvrp"]) {
const changed = snapshot("bridge", [{ ...bridge, [field]: field === "frame-types" ? "admit-only-vlan-tagged" : "false" === bridge[field as keyof typeof bridge] ? "true" : "false" }])
assert.notEqual(changed.fingerprint, original.fingerprint)
}
assert.equal(snapshot("bridge", [{ ...bridge, running: "false" }]).fingerprint, original.fingerprint)
assert.equal(resourceById("bridge").fields.safeParse({ mvrp: true }).success, false)
})
@@ -0,0 +1,135 @@
import { bridgeVlanStateSchema, type BridgeVlanDesired, type BridgeVlanState } from "@mmapp/contracts/bridge-vlan"
import { bridgeVlanCurrentDesired } from "../../services/bridge-vlan-configuration.js"
import type { ActionContext, ChangeEvidence } from "./action-types.js"
import { prepareBridgeVlanConfiguration, readBridgeVlanState } from "./bridge-vlan-actions.js"
import { acknowledgedCreateId, assertNoCallerCreateId, recoveryRosStepId } from "./native-create-identity.js"
import { readRosSnapshot, rosSnapshotVersion, type RosSnapshot, type RosStep } from "./resources.js"
import { verifyRosStepSnapshots } from "./ros-step-verification.js"
import { decryptSecret, fingerprint, McpError } from "./security.js"
type Checkpoint = { step_index: number; before_sealed: string; after_sealed: string | null }
type Intended = { step_index: number; state: string; result: unknown }
const key = (snapshot: Pick<RosSnapshot, "serverId" | "resource">) => `${snapshot.serverId}:${snapshot.resource}`
function conflict(message: string): never { throw new McpError("STATE_CONFLICT", message, 409) }
export function needsBridgeVlanRecovery(sourceAction: string, evidence: ChangeEvidence): boolean {
if (sourceAction === "network.bridge-vlan.configure") return true
return evidence.steps.some(change => {
if (change.kind !== "ros") return false
const step = change.step
if (step.resource === "bridge") return step.action === "update" && Object.keys(step.fields).some(field => ["pvid", "vlan-filtering", "frame-types", "ingress-filtering"].includes(field))
const sensitive = step.resource === "bridge_port" ? ["bridge", "interface", "pvid", "ingress-filtering", "frame-types", "disabled"]
: step.resource === "bridge_vlan" ? ["bridge", "vlan-ids", "tagged", "untagged", "disabled"] : []
return sensitive.length > 0 && (step.action !== "update" || Object.keys(step.fields).some(field => sensitive.includes(field)))
})
}
function validSnapshot(snapshot: RosSnapshot): boolean {
return snapshot.projectionVersion === rosSnapshotVersion(snapshot.resource) && !!snapshot.sourceProof && Array.isArray(snapshot.rows) && snapshot.fingerprint === fingerprint(snapshot.rows)
}
function sameSnapshot(left: RosSnapshot, right: RosSnapshot): boolean {
return validSnapshot(left) && validSnapshot(right) && key(left) === key(right) && left.fingerprint === right.fingerprint && left.sourceProof === right.sourceProof
}
function stateFromSnapshots(serverId: number, snapshots: Map<string, RosSnapshot>): BridgeVlanState {
const rows = (resource: string) => {
const snapshot = snapshots.get(key({ serverId, resource }))
if (!snapshot || !validSnapshot(snapshot)) conflict("Нет полного исходного снимка bridge VLAN с актуальной проекцией")
return snapshot.rows
}
return bridgeVlanStateSchema.parse({ bridges: rows("bridge"), ports: rows("bridge_port"), vlans: rows("bridge_vlan"),
vlanInterfaces: rows("vlan"), ipv4Addresses: rows("ipv4_address"), ipv6Addresses: rows("ipv6_address"),
interfaces: ["ethernet", "bridge", "vlan", "gre", "wireguard", "vxlan"].flatMap(resource => rows(resource).map(row => ({ resource, id: String(row[".id"]), name: String(row.name), disabled: row.disabled }))),
})
}
/** Fresh typed inverse of a completely verified transition. Deleted port/VLAN
* rows are recreated without their old IDs; new rows are removed only by ACK. */
export async function prepareBridgeVlanRecovery(input: {
operationId: string; evidence: ChangeEvidence; checkpoints: Checkpoint[]; intended: Intended[];
}, context: ActionContext): Promise<ChangeEvidence> {
const { operationId, evidence, checkpoints, intended } = input
if (!evidence.steps.length || evidence.steps.some(change => change.kind !== "ros" || !["bridge", "bridge_port", "bridge_vlan"].includes(change.step.resource))) {
throw new McpError("UNSUPPORTED_CAPABILITY", "Обратный VLAN план не поддерживает смешанные ресурсы", 409)
}
const native = evidence.steps.map(change => (change as { kind: "ros"; step: RosStep }).step)
if (native.some(step => step.resource === "bridge" && step.action !== "update")) {
throw new McpError("UNSUPPORTED_CAPABILITY", "Создание или удаление самого bridge не поддерживает типизированный обратный VLAN план", 409)
}
const serverIds = [...new Set(native.map(step => step.serverId))]
if (serverIds.length !== 1 || evidence.target.scope !== "servers" || evidence.target.serverIds.length !== 1 || evidence.target.serverIds[0] !== serverIds[0]) conflict("Нет однозначного сервера исходного VLAN плана")
const serverId = serverIds[0]
if (!context.connection.policy.scope.serverIds.includes(serverId)) throw new McpError("SERVER_DENIED", "VLAN сервер вне текущего допуска")
if (intended.length !== native.length || checkpoints.length !== native.length || new Set(intended.map(item => item.step_index)).size !== native.length || new Set(checkpoints.map(item => item.step_index)).size !== native.length) conflict("Нет полного набора подтверждённых шагов VLAN")
const baseline = new Map<string, RosSnapshot>()
for (const snapshot of evidence.snapshots) {
if (snapshot.serverId !== serverId || !validSnapshot(snapshot) || baseline.has(key(snapshot))) conflict("Исходные VLAN снимки или их версии не подтверждены")
baseline.set(key(snapshot), snapshot)
}
const baselineState = stateFromSnapshots(serverId, baseline), expected = new Map(baseline)
const created: Array<{ resource: "bridge_port" | "bridge_vlan"; id: string }> = []
const deleted: Array<{ resource: "bridge_port" | "bridge_vlan"; id: string }> = []
for (let index = 0; index < native.length; index++) {
const step = native[index], checkpoint = checkpoints.find(item => item.step_index === index), receipt = intended.find(item => item.step_index === index)
assertNoCallerCreateId(step)
if (!checkpoint?.before_sealed || !checkpoint.after_sealed || receipt?.state !== "verified") conflict("Нет полного проверенного checkpoint VLAN; требуется ручная сверка")
const before = JSON.parse(await decryptSecret(checkpoint.before_sealed)) as RosSnapshot
const after = JSON.parse(await decryptSecret(checkpoint.after_sealed)) as RosSnapshot
const previous = expected.get(key({ serverId, resource: step.resource }))
if (!previous || !sameSnapshot(previous, before) || !validSnapshot(after) || before.sourceProof !== after.sourceProof || !verifyRosStepSnapshots(step, receipt.result, before, after)) conflict("Исходный переход VLAN или источник не подтверждён")
if (step.action === "create") {
const ack = acknowledgedCreateId(receipt.result), actual = recoveryRosStepId(step, before.rows, after.rows)
if (!ack || actual !== ack || !["bridge_port", "bridge_vlan"].includes(step.resource)) conflict("Нет точного ACK и checkpoint ID созданного VLAN объекта")
created.push({ resource: step.resource as "bridge_port" | "bridge_vlan", id: ack })
} else if (step.action === "delete") {
if (!step.id || !["bridge_port", "bridge_vlan"].includes(step.resource)) conflict("Не подтверждён ID удалённого VLAN объекта")
deleted.push({ resource: step.resource as "bridge_port" | "bridge_vlan", id: step.id })
}
expected.set(key(after), after)
}
const bridgeNames = new Set(native.map(step => {
const rows = step.resource === "bridge" ? baselineState.bridges : step.resource === "bridge_port" ? baselineState.ports : baselineState.vlans
const row = rows.find(item => item[".id"] === step.id)
const name = step.resource === "bridge" ? row?.name : step.fields.bridge ?? row?.bridge
if (typeof name !== "string" || !name) conflict("Не подтверждён исходный bridge VLAN")
return name
}))
const bridges = baselineState.bridges.filter(row => bridgeNames.has(String(row.name)))
if (bridgeNames.size !== 1 || bridges.length !== 1) conflict("Обратный VLAN план требует один однозначный bridge")
const bridgeId = String(bridges[0][".id"]), desired: BridgeVlanDesired = bridgeVlanCurrentDesired(baselineState, bridgeId)
for (const item of deleted) {
const terminal = expected.get(key({ serverId, resource: item.resource }))
const original = item.resource === "bridge_port" ? desired.ports.find(row => row.id === item.id) : desired.vlans.find(row => row.id === item.id)
if (!original || !terminal || terminal.rows.some(row => row[".id"] === item.id)) conflict("Нет подтверждённого удаления исходной VLAN identity")
delete original.id
}
for (const item of created) {
const originalRows = baseline.get(key({ serverId, resource: item.resource }))!.rows
const terminalRows = expected.get(key({ serverId, resource: item.resource }))!.rows
if (originalRows.some(row => row[".id"] === item.id) || !terminalRows.some(row => row[".id"] === item.id)) conflict("ACK ID VLAN не является подтверждённым новым объектом")
if (item.resource === "bridge_port") desired.removePortIds.push(item.id)
else desired.removeVlanIds.push(item.id)
}
const compare = async (snapshots: RosSnapshot[]): Promise<RosSnapshot[]> => {
const captured = new Map(snapshots.map(snapshot => [key(snapshot), snapshot]))
for (const snapshot of snapshots) {
const terminal = expected.get(key(snapshot))
if (!terminal || !sameSnapshot(terminal, snapshot)) conflict("После операции изменились VLAN конфигурация, зависимости или источник; обратный план запрещён")
}
for (const [resourceKey, terminal] of expected) {
const current = captured.get(resourceKey) ?? await readRosSnapshot(serverId, terminal.resource, undefined, terminal.sourceProof)
if (!sameSnapshot(terminal, current)) conflict("После операции обнаружен drift зависимости VLAN; обратный план запрещён")
captured.set(resourceKey, current)
}
return [...captured.values()]
}
await compare((await readBridgeVlanState(serverId, context)).snapshots)
// Reconstruct through the same model, with a freshly verified management
// channel. Old receipts never confer authority to run the inverse.
const inverse = await prepareBridgeVlanConfiguration({ serverId, bridgeId, desired }, context)
const snapshots = await compare(inverse.snapshots)
const objects = new Map([...evidence.objects, ...inverse.objects].map(object => [`${object.serverId}/${object.kind}/${object.id}`, object]))
return { ...inverse, snapshots, objects: [...objects.values()], requiresHuman: true,
warnings: [`Типизированный обратный VLAN план операции ${operationId}; все исходные шаги и ACK проверены.`, ...inverse.warnings,
"Удалённые port/VLAN rows создаются с новыми ID RouterOS. Unknown операции не повторяются; восстановление самого bridge не поддерживается."],
}
}
@@ -1,5 +1,5 @@
import assert from "node:assert/strict"
import { randomUUID, createHmac } from "node:crypto"
import { randomUUID, createHash, createHmac } from "node:crypto"
import { readFile, writeFile } from "node:fs/promises"
import path from "node:path"
import type { PoolClient } from "pg"
@@ -8,7 +8,7 @@ import { pool, closePool } from "../../db/index.js"
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
import { type AuthUser } from "../../lib/permissions.js"
import { accessGeneration, mcpStateDirectory } from "./security.js"
import { createMcpConnection, authenticateMcpToken, updateMcpSettings } from "./store.js"
import { createMcpConnection, authenticateMcpToken, validatePrincipal, updateMcpSettings } from "./store.js"
import { invokeMcpTool, approvePlan, applyPlan } from "./runtime.js"
import { uploadRestoreArchive, readRestoreReceipt, executeMcpDatabaseRestore } from "./database-restore.js"
import { exportSystemDatabaseBackup, restoreSystemDatabaseBackup } from "../../services/system-database-backup.js"
@@ -51,25 +51,39 @@ try {
await updateMcpSettings({ enabled: true }, actor)
await pool.query("CREATE TABLE mcp_restore_acceptance_marker (value text NOT NULL)")
await pool.query("INSERT INTO mcp_restore_acceptance_marker VALUES('before')")
const dump = await exportSystemDatabaseBackup()
assert.equal(dump.buffer.subarray(0, 5).toString(), "PGDMP")
if (verifyData) dataExpectation = await (await import("./database-restore-data-equivalence.sql-helper.js")).prepareOwnedRestoreDataExpectation(dump.buffer)
const revokedOwner = { id: "restore-revocation-after-backup", issuedAt: Date.now() - 60000 }
const portalSecret = "restore-acceptance-only-portal-secret-32", stamp = String(Math.floor(Date.now() / 1000))
const portalEvent = Buffer.from(JSON.stringify({ eventId: randomUUID(), userId: revokedOwner.id, type: "permissions.changed" }))
await acceptPortalRevocation(portalEvent, stamp, "sha256=" + createHmac("sha256", portalSecret).update(stamp + ".").update(portalEvent).digest("hex"), portalSecret)
const source = await uploadRestoreArchive(dump.buffer, actor)
const encrypted = await readFile(path.join(mcpStateDirectory(), "restore-vault", source.archiveRef + ".archive.bin"))
assert.ok(!encrypted.equals(dump.buffer))
assert.ok(!encrypted.subarray(0, 5).equals(Buffer.from("PGDMP")))
await assert.rejects(uploadRestoreArchive(dump.buffer, { ...actor, permissions: ["mm:mcp:admin"] }))
await assert.rejects(uploadRestoreArchive(Buffer.from("invalid"), actor))
const serverIds = (await pool.query("SELECT id FROM servers")).rows.map(row => Number(row.id))
// Combined owned profiles retain their evidence/disabled synthetic servers.
// The human restore fixture grants this exact catalog instead of inheriting
// the ordinary five-server default; production policy guards stay unchanged.
assert(serverIds.length <= 1000)
const issue = (owner = actor) => createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Database restore acceptance", mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:settings:admin"], scope: { allowGlobal: true, serverIds, operationIds: ["database.restore"] }, limits: { serversPerPlan: Math.max(1, serverIds.length) } }), owner)
const cutoff = Date.now() - 1000
const revokedOwner = { ...actor, id: "restore-revocation-after-backup", issuedAt: cutoff - 1 }
const oldAccess = await issue(revokedOwner), oldPrincipal = await authenticateMcpToken(oldAccess.token)
const readHistoricalAccess = async () => (await pool.query<{ status: string; credential_current: boolean; generation: string }>(
"SELECT c.policy->>'status' AS status, k.revoked_at IS NULL AND k.expires_at>now() AS credential_current, c.generation FROM mcp_connections c JOIN mcp_credentials k ON k.connection_id=c.id WHERE c.id=$1 AND k.id=$2",
[oldPrincipal.connectionId, oldPrincipal.credentialId],
)).rows[0]
const beforeDumpAccess = await readHistoricalAccess()
assert.deepEqual(beforeDumpAccess, { status: "active", credential_current: true, generation: await accessGeneration() })
const dump = await exportSystemDatabaseBackup()
assert.equal(dump.buffer.subarray(0, 5).toString(), "PGDMP")
if (verifyData) dataExpectation = await (await import("./database-restore-data-equivalence.sql-helper.js")).prepareOwnedRestoreDataExpectation(dump.buffer)
const portalSecret = "restore-acceptance-only-portal-secret-32", stamp = String(Math.floor(Date.now() / 1000))
const eventId = randomUUID()
const portalEvent = Buffer.from(JSON.stringify({ version: 2, eventId, userId: revokedOwner.id, type: "permissions.changed", eventOccurredAt: cutoff }))
assert.deepEqual(await acceptPortalRevocation(portalEvent, stamp, "sha256=" + createHmac("sha256", portalSecret).update(stamp + ".").update(portalEvent).digest("hex"), portalSecret), { ok: true, duplicate: false })
const cutoffFile = path.join(mcpStateDirectory(), "owner-revocations", createHash("sha256").update(revokedOwner.id).digest("hex") + ".json")
const cutoffBytes = await readFile(cutoffFile)
assert.deepEqual(JSON.parse(cutoffBytes.toString("utf8")), { invalidBefore: cutoff })
assert.deepEqual(await readHistoricalAccess(), { ...beforeDumpAccess, status: "revoked", credential_current: false })
await assert.rejects(validatePrincipal(oldPrincipal), { code: "OWNER_CLAIMS_REVOKED" })
const source = await uploadRestoreArchive(dump.buffer, actor)
const encrypted = await readFile(path.join(mcpStateDirectory(), "restore-vault", source.archiveRef + ".archive.bin"))
assert.ok(!encrypted.equals(dump.buffer))
assert.ok(!encrypted.subarray(0, 5).equals(Buffer.from("PGDMP")))
await assert.rejects(uploadRestoreArchive(dump.buffer, { ...actor, permissions: ["mm:mcp:admin"] }))
await assert.rejects(uploadRestoreArchive(Buffer.from("invalid"), actor))
const issued = await issue(), principal = await authenticateMcpToken(issued.token)
const other = await issue({ ...actor, id: "another-owner" }), otherPrincipal = await authenticateMcpToken(other.token)
await assert.rejects(invokeMcpTool(otherPrincipal, "mm_database_restore_plan", { archiveRef: source.archiveRef, expectedSha256: source.sha256 }))
@@ -144,13 +158,26 @@ try {
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_operations WHERE id=$1", [operation.id])).rows[0].n, 0)
assert.notEqual(await accessGeneration(), generation)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_owner_revocations WHERE owner_id=$1", [revokedOwner.id])).rows[0].n, 0)
await assert.rejects(assertPortalClaimsCurrent(revokedOwner))
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_portal_events WHERE id=$1", [eventId])).rows[0].n, 0)
assert.deepEqual(await readHistoricalAccess(), beforeDumpAccess)
assert.deepEqual(await readFile(cutoffFile), cutoffBytes)
// Both paths must report the external owner cutoff, independently of the
// blanket generation change that also invalidates older connections.
await assert.rejects(assertPortalClaimsCurrent(revokedOwner), { code: "OWNER_CLAIMS_REVOKED" })
await assert.rejects(authenticateMcpToken(oldAccess.token), { code: "OWNER_CLAIMS_REVOKED" })
await assert.rejects(validatePrincipal(oldPrincipal), { code: "OWNER_CLAIMS_REVOKED" })
await assert.rejects(authenticateMcpToken(issued.token))
await assert.rejects(readRestoreReceipt(operation.id, { ...actor, permissions: ["mm:mcp:read"] }))
// The applied receipt is durable before the terminal callback releases its
// last SQL lease/exclusive admission. Await that existing owner, rather than
// racing its cleanup with the stale-callback/no-replay assertion below.
await waitForDatabaseRetirement()
await assert.rejects(issue(revokedOwner), { code: "OWNER_CLAIMS_REVOKED" })
const refreshedOwnerAccess = await issue({ ...revokedOwner, issuedAt: cutoff + 1 })
const refreshedOwner = await validatePrincipal(await authenticateMcpToken(refreshedOwnerAccess.token))
assert.equal(refreshedOwner.owner_id, revokedOwner.id)
assert.equal(refreshedOwner.generation, await accessGeneration())
assert.notEqual(refreshedOwner.generation, beforeDumpAccess.generation)
// A stale callback or process replay cannot invoke pg_restore a second time.
await pool.query("UPDATE mcp_restore_acceptance_marker SET value='after-receipt'")
await executeMcpDatabaseRestore(operation.id, principal)
@@ -159,7 +186,7 @@ try {
if (dataExpectation) {
console.log("Owned restore data negative cases: foreign owner, wrong operation and unconfirmed receipt refused; repeat validation writes no audit; full equality observed before consumers resume")
}
console.log("✓ Terminal restore: real pg_dump/pg_restore transaction, encrypted human upload, approval, global protection, request drain, external receipt, revoked tokens, preserved external owner revocation and no replay")
console.log("✓ Terminal restore: real pg_dump/pg_restore transaction, encrypted human upload, approval, global protection, request drain, external receipt, restored live credential refused by external Portal cutoff, post-cutoff claims admitted and no replay")
// Archive authentication is checked before a terminal attempt can replace data.
await updateMcpSettings({ enabled: true }, actor)
@@ -1,6 +1,9 @@
import assert from "node:assert/strict"
import { createHash, randomUUID } from "node:crypto"
import { createDiagnosticCancellationSourceForTests, createReadServerProbeExecutionSourceForTests } from "./diagnostic-cancellation-source.js"
import { mkdtemp, readdir, rm, rmdir } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createDiagnosticCancellationSourceForTests, createReadServerProbeExecutionSourceForTests, readServerProbeExecutionSource } from "./diagnostic-cancellation-source.js"
import { fingerprint, redact } from "./security.js"
const proof = async (value: unknown) => createHash("sha256").update(fingerprint(value)).digest("hex")
@@ -25,6 +28,7 @@ function fixture() {
return { rows }
} }
return { load: () => load(client, operationId, owner), read: () => read(snapshot, operationId, owner), readSql,
readWithExistingKey: () => readServerProbeExecutionSource(snapshot, operationId, owner),
refresh, binding, input, evidence, row, sealed, setRows: (value: typeof rows) => { rows=value }, calls: () => calls, operationId, credentialId }
}
{
@@ -100,4 +104,22 @@ for (const mutate of mutations) {
assert.equal(source.originalContext.tool, "bandwidth"); assert.equal(source.stageBudget, 1)
assert.equal(source.originalContext.identity.credentialId, f.credentialId)
}
console.log(`diagnostic-cancellation-source: locked/read-only source, expired original contexts and ${mutations.length} shared tamper checks passed`)
{
const state = await mkdtemp(join(tmpdir(), "mm-legacy-source-test-"))
const previousState = process.env.MCP_STATE_DIR
process.env.MCP_STATE_DIR = state
try {
const f = fixture()
await f.refresh()
f.row.evidence = { sealed: Buffer.alloc(32).toString("base64") }
await assert.rejects(f.readWithExistingKey, { code: "STATE_CONFLICT" })
assert.equal(f.readSql.length, 1)
assert.deepEqual(await readdir(state), [], "Historical source reads must leave missing encryption state untouched")
} finally {
if (previousState === undefined) delete process.env.MCP_STATE_DIR
else process.env.MCP_STATE_DIR = previousState
await rm(join(state, "encryption-key"), { force: true })
await rmdir(state)
}
}
console.log(`diagnostic-cancellation-source: locked/read-only source, missing key, expired original contexts and ${mutations.length} shared tamper checks passed`)
@@ -3,7 +3,7 @@ import type { ProbeStorageScope, ProbeStorageSql } from "../../services/server-p
import type { ServerProbeJournalContext } from "../../services/server-probe-journal.js"
import { SERVER_PROBE_OPERATION } from "../../services/server-probe-authority.js"
import { openServerProbeMcpBinding } from "./server-probe-actions.js"
import { decryptSecret, fingerprint, privateSecretFingerprint, redact, McpError } from "./security.js"
import { decryptSecret, decryptSecretExisting, fingerprint, privateFingerprinterExisting, privateSecretFingerprint, redact, McpError } from "./security.js"
const MAX_BYTES = 2097152
const uuid = z.string().uuid()
@@ -113,7 +113,10 @@ export const loadDiagnosticCancellationSource = sourceLoader(productionCrypto)
/** Test-only crypto seam. Never accept these dependencies from tool JSON. */
export const createDiagnosticCancellationSourceForTests = sourceLoader
/** Historical authentication only; confers no execution/cancellation grant. */
export const readServerProbeExecutionSource = readSourceLoader(productionCrypto)
export const readServerProbeExecutionSource = readSourceLoader({
open: decryptSecretExisting,
proof: async value => (await privateFingerprinterExisting("server-probe-mcp-binding-v1"))(fingerprint(value)),
})
export const createReadServerProbeExecutionSourceForTests = readSourceLoader
/** Exact previously locked source projection for private history conversion. */
export function authenticateServerProbeExecutionSourceRow(row: Record<string, unknown>, operationId: string,
+45 -1
View File
@@ -1,11 +1,12 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { analyzeEcmpPreflight, preflightObservation, preflightPaths, type PreflightObservation, type PreflightSource } from "./ecmp-preflight.js"
import { analyzeEcmpPreflight, duplicateEcmpAdjacency, ecmpAdjacency, preflightObservation, preflightPaths, type PreflightObservation, type PreflightSource } from "./ecmp-preflight.js"
function fixture(): Record<PreflightSource, PreflightObservation> {
const result = Object.fromEntries(Object.keys(preflightPaths("ipv4")).map(key => [key, { rows: [], error: null }])) as Record<PreflightSource, PreflightObservation>
result.routes.rows = [1, 2].map(index => ({ ".id": `*${index}`, "dst-address": "192.0.2.0/24", "routing-table": "main", gateway: `192.0.2.${index}`, "immediate-gw": `192.0.2.${index}%gre${index}`, disabled: "false", active: index === 1 ? "true" : "false", distance: String(index * 10) }))
result.settings.rows = [{ "rp-filter": "no", "ipv4-multipath-hash-policy": "l3", "multipath-hash-policy": "l4" }]
result.tables.rows = [{ ".id": "*main", name: "main", fib: "true" }]
result.connectionTracking.rows = [{ enabled: "auto" }]
return result
}
@@ -15,6 +16,23 @@ test("preflight observes unequal/inactive candidates without claiming packet/pat
assert.equal(result.decision, "review-required"); assert.equal(result.independentPaths, "not-proven"); assert.equal(result.networkValidation, "not-performed")
assert.equal(result.hashPolicy, "l3"); assert.deepEqual(result.findings, [])
})
test("preflight flags missing, ambiguous or inactive FIB without accepting unrelated tables", () => {
for (const tables of [
[],
[{ ".id": "*main", name: "main" }],
[{ ".id": "*main", name: "main", fib: "" }],
[{ ".id": "*main", name: "main", fib: "false" }],
[{ ".id": "*main", name: "main", fib: "true", disabled: "true" }],
[{ ".id": "*main", name: "main", fib: "true", invalid: "true" }],
[{ ".id": "*main", name: "main", fib: "true" }, { ".id": "*alias", name: "main", fib: "true" }],
]) {
const sources = fixture()
sources.tables.rows = [...tables, { ".id": "*other", name: "other", fib: "true" }]
const result = analyzeEcmpPreflight("ipv4", ["*1", "*2"], sources)
assert.equal(result.configurationObserved, false)
assert.deepEqual(result.findings.find(item => item.code === "FIB_TABLE_UNCONFIRMED")?.ids, ["*1", "*2"])
}
})
test("NAT, FastTrack, marks, notrack, strict RPF and shared egress require explicit review", () => {
const sources = fixture()
sources.filter.rows = [{ ".id": "*fast", action: "fasttrack-connection", disabled: "false" }, { ".id": "*disabled", action: "fasttrack-connection", disabled: "true" }]
@@ -52,3 +70,29 @@ test("IPv6 uses IPv6 paths/hash policy and explicitly has no IPv4 RPF setting",
assert.deepEqual(result.rpf, { supported: false, mode: null }); assert.equal(result.hashPolicy, "l4")
assert.ok(!result.findings.some(item => item.code.startsWith("RPF")))
})
test("ECMP identifies adjacency by canonical address and egress link", () => {
const duplicate = (first: string, second: string) => duplicateEcmpAdjacency([ecmpAdjacency(first), ecmpAdjacency(second)])
assert.ok(duplicate("fe80::1%gre1@main", "fe80:0:0:0:0:0:0:1%gre1@tenant"))
assert.ok(!duplicate("fe80::1%gre1", "fe80::1%gre2"))
assert.ok(!duplicate("192.0.2.1%gre1", "192.0.2.2%gre1"))
assert.ok(duplicate("192.0.2.1", "192.0.2.1%gre1"))
assert.equal(ecmpAdjacency("fe80::1"), null)
assert.equal(ecmpAdjacency("192.0.2.1%gre1,192.0.2.2%gre2"), null)
assert.equal(ecmpAdjacency("gre1"), null)
assert.deepEqual(ecmpAdjacency("gre1", new Set(["gre1"])), { address: null, interface: "gre1" })
const sources = fixture()
sources.routes.rows[1]["immediate-gw"] = sources.routes.rows[0]["immediate-gw"]
assert.ok(analyzeEcmpPreflight("ipv4", ["*1", "*2"], sources).findings.some(item => item.code === "DUPLICATE_IMMEDIATE_GATEWAY"))
})
test("interface-only ECMP next-hop requires a unique observed name", () => {
const sources = fixture()
sources.routes.rows[0]["immediate-gw"] = "gre2"
const analyze = (rows: Record<string, unknown>[]) => analyzeEcmpPreflight("ipv4", ["*1", "*2"], sources, preflightObservation(rows))
const known = analyze([{ ".id": "*gre", name: "gre2" }])
assert.equal(known.configurationObserved, true)
assert.ok(known.findings.some(item => item.code === "SHARED_EGRESS_INTERFACE"))
const ambiguous = analyze([{ ".id": "*gre", name: "gre2" }, { ".id": "*other", name: "gre2" }])
assert.equal(ambiguous.configurationObserved, false)
assert.ok(ambiguous.findings.some(item => item.code === "EGRESS_INTERFACE_UNKNOWN"))
assert.equal(analyze([{ name: "gre2" }]).sources.interfaces.observed, false)
})
+63 -4
View File
@@ -1,4 +1,16 @@
import { ipv6ToInt } from "../../services/traffic-flow-ip.js"
import { isConfirmedRoutingFibTable } from "../../services/routing-table-fib.js"
import { routeGatewayContext } from "./routing-community-context-model.js"
type Row = Record<string, unknown>
export function confirmedEcmpTable(rows: readonly Row[], name: string) {
const matches = rows.filter(row => row.name === name)
if (matches.length !== 1) return undefined
const table = matches[0], id = table[".id"]
// The /routing/table catalogue can return an empty FIB value for a non-FIB table.
if (typeof id !== "string" || !id.trim() || table.fib === "" || !isConfirmedRoutingFibTable(table)) return undefined
return { id }
}
export type PreflightObservation = { rows: Row[]; error: string | null }
export const preflightPaths = (family: "ipv4" | "ipv6") => {
const base = family === "ipv4" ? "ip" : "ipv6"
@@ -17,12 +29,48 @@ export function preflightObservation(raw: unknown, singleton = false): Preflight
const text = (value: unknown) => typeof value === "string" && value.trim() ? value.trim() : null
const flag = (value: unknown) => value === true || value === "true" || value === "yes" ? true : value === false || value === "false" || value === "no" ? false : null
export function ecmpAdjacency(value: unknown, interfaces?: ReadonlySet<string>) {
if (typeof value !== "string" || !value || /[\r\n,]/.test(value)) return null
try {
const hop = routeGatewayContext(value)
const address = hop.family === 6 ? ipv6ToInt(hop.address) : hop.address
// fe80::/10 needs a confirmed link to identify the adjacency.
if (address === null || (typeof address === "bigint" && address >> 118n === 0x3fan && hop.interface === null)) return null
return { address, interface: hop.interface }
} catch {
return interfaces?.has(value) ? { address: null, interface: value } : null
}
}
export function confirmedEcmpInterfaces(rows: readonly Row[]) {
const names = new Map<string, string>()
const ambiguous = new Set<string>()
for (const row of rows) {
if (typeof row.name !== "string" || !row.name.trim() || typeof row[".id"] !== "string" || !row[".id"].trim()) continue
if (names.has(row.name)) ambiguous.add(row.name)
names.set(row.name, row[".id"])
}
for (const name of ambiguous) names.delete(name)
return names
}
export function confirmedEcmpInterfaceNames(rows: readonly Row[]) {
return new Set(confirmedEcmpInterfaces(rows).keys())
}
export function duplicateEcmpAdjacency(hops: ReturnType<typeof ecmpAdjacency>[]) {
return hops.some((hop, index) => hop && hops.slice(0, index).some(previous =>
previous && previous.address === hop.address
&& (previous.interface === hop.interface || previous.interface === null || hop.interface === null)))
}
/** Configuration evidence only. Rule presence does not prove packet matching,
* physical independence, NAT affinity, return path or traffic distribution. */
export function analyzeEcmpPreflight(family: "ipv4" | "ipv6", routeIds: string[], observed: Record<PreflightSource, PreflightObservation>) {
export function analyzeEcmpPreflight(family: "ipv4" | "ipv6", routeIds: string[], observed: Record<PreflightSource, PreflightObservation>, inventory?: PreflightObservation) {
const findings: { code: string; level: "review" | "unknown"; resource: string; ids: string[]; objectCount: number; idsTruncated: boolean; message: string }[] = []
const add = (code: string, level: "review" | "unknown", resource: string, ids: string[], message: string) => findings.push({ code, level, resource, ids: ids.slice(0, 100), objectCount: ids.length, idsTruncated: ids.length > 100, message })
for (const [resource, observation] of Object.entries(observed)) if (observation.error) add("OBSERVATION_UNAVAILABLE", "unknown", resource, [], observation.error)
const sources = inventory ? { ...observed, interfaces: inventory } : observed
for (const [resource, observation] of Object.entries(sources)) if (observation.error) add("OBSERVATION_UNAVAILABLE", "unknown", resource, [], observation.error)
const candidates = routeIds.map(id => observed.routes.rows.find(row => row[".id"] === id))
const selected = candidates.filter((row): row is Row => !!row)
if (selected.length !== routeIds.length) add("ROUTE_NOT_OBSERVED", "unknown", "routes", routeIds.filter((_id, index) => !candidates[index]), "Не все выбранные маршруты подтверждены")
@@ -30,9 +78,20 @@ export function analyzeEcmpPreflight(family: "ipv4" | "ipv6", routeIds: string[]
if (routeSummary.some(row => !row.prefix || !row.table || !row.gateway || !row.immediateGateway || row.disabled === null)) add("ROUTE_CONTEXT_UNKNOWN", "unknown", "routes", routeSummary.map(row => row.id), "Не подтверждены prefix/table/gateway/disabled; отсутствие свойства не считается значением по умолчанию")
if (routeSummary.some(row => row.disabled === true)) add("DISABLED_ROUTE", "review", "routes", routeSummary.filter(row => row.disabled === true).map(row => row.id), "В наборе есть отключённые маршруты")
if (new Set(routeSummary.map(row => row.prefix).filter(Boolean)).size > 1 || new Set(routeSummary.map(row => row.table).filter(Boolean)).size > 1) add("DIFFERENT_ROUTE_CONTEXTS", "review", "routes", routeSummary.map(row => row.id), "Prefix или routing table выбранных маршрутов различаются")
if (observed.tables.error === null) {
const unconfirmed = new Set<string>()
for (const table of new Set(routeSummary.map(row => row.table))) {
if (table !== null && !confirmedEcmpTable(observed.tables.rows, table)) unconfirmed.add(table)
}
const affected = routeSummary.filter(row => row.table !== null && unconfirmed.has(row.table))
if (affected.length) add("FIB_TABLE_UNCONFIRMED", "unknown", "routes", affected.map(row => row.id), "Для выбранных маршрутов не подтверждена однозначная активная таблица FIB")
}
const knownGateways = routeSummary.map(row => row.gateway).filter(Boolean)
if (new Set(knownGateways).size !== knownGateways.length) add("DUPLICATE_GATEWAY", "review", "routes", routeSummary.map(row => row.id), "Next-hop выбранных маршрутов повторяется")
const interfaces = routeSummary.map(row => row.immediateGateway?.split("%")[1]?.split("@")[0] || null)
const names = inventory?.error === null ? confirmedEcmpInterfaceNames(inventory.rows) : undefined
const hops = selected.map(row => ecmpAdjacency(row["immediate-gw"], names))
if (duplicateEcmpAdjacency(hops)) add("DUPLICATE_IMMEDIATE_GATEWAY", "review", "routes", routeSummary.map(row => row.id), "Выбранные маршруты разрешаются в один и тот же next-hop")
const interfaces = hops.map(hop => hop?.interface ?? null)
if (interfaces.some(iface => iface === null)) add("EGRESS_INTERFACE_UNKNOWN", "unknown", "routes", routeSummary.map(row => row.id), "Не подтверждён интерфейс immediate gateway")
else if (new Set(interfaces).size < interfaces.length) add("SHARED_EGRESS_INTERFACE", "review", "routes", routeSummary.map(row => row.id), "Несколько next-hop используют один интерфейс; независимость путей не подтверждена")
const maybeEnabled = (resource: PreflightSource) => {
@@ -59,5 +118,5 @@ export function analyzeEcmpPreflight(family: "ipv4" | "ipv6", routeIds: string[]
if (!connectionTracking) add("CONNECTION_TRACKING_UNKNOWN", "unknown", "connectionTracking", [], "Режим connection tracking не подтверждён")
else if (connectionTracking === "no" || connectionTracking === "false") add("CONNECTION_TRACKING_DISABLED", "review", "connectionTracking", [], "Connection tracking отключён; проверить зависимости NAT/stateful firewall")
return { family, decision: "review-required", configurationObserved: !findings.some(item => item.level === "unknown"), networkValidation: "not-performed", independentPaths: "not-proven", routeSummary, hashPolicy, rpf: { supported: family === "ipv4", mode: rpf }, connectionTracking,
sources: Object.fromEntries(Object.entries(observed).map(([key, value]) => [key, { observed: value.error === null, count: value.rows.length }])), findings }
sources: Object.fromEntries(Object.entries(sources).map(([key, value]) => [key, { observed: value.error === null, count: value.rows.length }])), findings }
}
+111 -15
View File
@@ -1282,6 +1282,7 @@ try {
preflightFixtures = Object.fromEntries(Object.values(preflightPaths("ipv4")).map(path => ["/rest" + path, []]))
preflightFixtures["/rest/ip/route"] = [1, 2].map(index => ({ ".id": `*p${index}`, "dst-address": "192.0.2.0/24", gateway: `192.0.2.${index}`, "immediate-gw": `192.0.2.${index}%same-gre`, "routing-table": "main", distance: "10", active: "true", disabled: "false" }))
preflightFixtures["/rest/ip/settings"] = { "rp-filter": "strict", "ipv4-multipath-hash-policy": "l3" }
preflightFixtures["/rest/routing/table"] = [{ ".id": "*main", name: "main", fib: "true" }]
preflightFixtures["/rest/ip/firewall/connection/tracking"] = { enabled: "auto" }
preflightFixtures["/rest/ip/firewall/filter"] = [{ ".id": "*ft", action: "fasttrack-connection", disabled: "false" }]
preflightFixtures["/rest/ip/firewall/nat"] = [{ ".id": "*nat", action: "masquerade", disabled: "false" }]
@@ -1293,6 +1294,11 @@ try {
assert.equal(preflight.configurationObserved, true); assert.equal(preflight.networkValidation, "not-performed"); assert.equal(catalogCalls, 10)
for (const code of ["SHARED_EGRESS_INTERFACE", "STRICT_RPF_REVIEW", "FASTTRACK_REVIEW", "NAT_AFFINITY_REVIEW"]) assert.ok(preflight.findings.some(item => item.code === code), code)
await assert.rejects(invokeMcpTool(preflightPrincipal, "mm_ecmp_preflight_get", { ...preflightInput, serverId: 2 })); assert.equal(catalogCalls, 10)
preflightFixtures["/rest/routing/table"] = [{ ".id": "*main", name: "main", fib: "" }]
const noFibPreflight = await invokeMcpTool(preflightPrincipal, "mm_ecmp_preflight_get", preflightInput) as typeof preflight
assert.equal(noFibPreflight.configurationObserved, false)
assert.ok(noFibPreflight.findings.some(item => item.code === "FIB_TABLE_UNCONFIRMED"))
preflightFixtures["/rest/routing/table"] = [{ ".id": "*main", name: "main", fib: "true" }]
preflightUnavailable = "/rest/ip/firewall/nat"
const unknownPreflight = await invokeMcpTool(preflightPrincipal, "mm_ecmp_preflight_get", preflightInput) as typeof preflight
assert.equal(unknownPreflight.configurationObserved, false); assert.ok(unknownPreflight.findings.some(item => item.code === "OBSERVATION_UNAVAILABLE")); assert.equal(writes, preflightWritesBefore)
@@ -2224,7 +2230,7 @@ try {
await pool.query("INSERT INTO mcp_routing_observations(id,src_server_id,dst_server_id,src_interface,dst_interface,family,routing_table,ping_ms,loss_pct,dl_mbps,ul_mbps,ping_at,bandwidth_at,context_verified,source) VALUES($1,1,2,'fixture-gre','peer','ipv4','main',12,0,100,200,now(),now(),true,'fixture')", [observationId])
await attestRoutingFixture(observationId)
try {
type OspfAnalysis = { ranking: { currentCost: number; proposedCost: number; observationId: string; peerId: number }[]; serverIds: number[]; changes: number; proposalOnly: boolean; executionAvailable: boolean }
type OspfAnalysis = { ranking: { currentCost: number; proposedCost: number; observationId: string; bandwidthObservationId: string | null; peerId: number }[]; serverIds: number[]; changes: number; proposalOnly: boolean; executionAvailable: boolean }
const applicationPreviewPolicy={name:"OSPF application preview",mode:"observer",expiresAt:new Date(Date.now()+1800000).toISOString(),permissions:["mm:network:read"],scope:{serverIds:[1,2],operationIds:["routing.ospf.application.preview"],allowGlobal:false}}
const applicationPreviewAccess=await createMcpConnection(mcpConnectionCreateSchema.parse(applicationPreviewPolicy),actor),applicationPreviewPrincipal=await authenticateMcpToken(applicationPreviewAccess.token)
const isolatedPreviewAccess=await createMcpConnection(mcpConnectionCreateSchema.parse({...applicationPreviewPolicy,name:"OSPF source-only preview",scope:{...applicationPreviewPolicy.scope,serverIds:[1]}}),actor),isolatedPreviewPrincipal=await authenticateMcpToken(isolatedPreviewAccess.token)
@@ -2388,10 +2394,14 @@ try {
})}finally{countCatalogCalls=false}
assert.equal((await (await import("./routing-observations.js")).readRoutingObservations([1, 2]))[0].sourceVerified, false, "Disable/re-enable must invalidate previous source attestation")
await attestRoutingFixture(observationId)
const pingOnlyId = randomUUID(), otherPeerInterfaceId = randomUUID()
const insertPingObservation = (id: string, peerInterface: string) => pool.query("INSERT INTO mcp_routing_observations(id,src_server_id,dst_server_id,src_interface,dst_interface,family,routing_table,ping_ms,loss_pct,ping_at,context_verified,source) VALUES($1,1,2,'fixture-gre',$2,'ipv4','main',11,0,now(),true,'fixture')", [id, peerInterface])
await insertPingObservation(pingOnlyId, "peer")
await attestRoutingFixture(pingOnlyId)
const proposal = await invokeMcpTool(analysisPrincipal, "mm_ospf_optimizer_analyze", { serverId: 1 }) as OspfAnalysis
assert.deepEqual(proposal.serverIds, [1, 2]); assert.equal(proposal.changes, 1)
assert.equal(proposal.ranking[0].currentCost, 30); assert.equal(proposal.ranking[0].proposedCost, 10)
assert.equal(proposal.ranking[0].observationId, observationId); assert.equal(proposal.ranking[0].peerId, 2)
assert.equal(proposal.ranking[0].observationId, pingOnlyId); assert.equal(proposal.ranking[0].bandwidthObservationId, observationId); assert.equal(proposal.ranking[0].peerId, 2)
assert.equal(proposal.proposalOnly, true); assert.equal(proposal.executionAvailable, false)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_plans WHERE connection_id=$1", [analysisAccess.connection.id])).rows[0].n, 0)
assert.equal(ospfAnalysisDispatches, 0)
@@ -2405,6 +2415,9 @@ try {
await verifyRoutingObservationChecks(observationEvidence.observationChecks)
await assert.rejects(verifyRoutingObservationChecks(observationEvidence.observationChecks.map((check: Record<string, unknown>) => ({ ...check, validUntil: new Date(Date.now() - 1).toISOString() }))))
await approvePlan(prepared.planId, actor, true)
await insertPingObservation(otherPeerInterfaceId, "another-peer-interface")
await attestRoutingFixture(otherPeerInterfaceId)
await verifyRoutingObservationChecks(observationEvidence.observationChecks)
await pool.query("UPDATE mcp_routing_observations SET created_at=now()-interval '1 minute' WHERE id=$1", [observationId])
await attestRoutingFixture(observationId)
await pool.query("INSERT INTO mcp_routing_observations(id,src_server_id,dst_server_id,src_interface,dst_interface,family,routing_table,ping_ms,loss_pct,dl_mbps,ul_mbps,ping_at,bandwidth_at,context_verified,source) VALUES($1,1,2,'fixture-gre','peer','ipv4','main',12,50,100,200,now(),now(),true,'fixture')", [badObservationId])
@@ -2422,7 +2435,7 @@ try {
await pool.query("UPDATE mcp_routing_observations SET loss_pct=0,context_verified=false WHERE id=$1", [badObservationId])
await attestRoutingFixture(badObservationId)
await assert.rejects(invokeMcpTool(analysisPrincipal, "mm_ospf_optimizer_analyze", { serverId: 1 }))
await pool.query("DELETE FROM mcp_routing_observations WHERE id=$1", [badObservationId])
await pool.query("DELETE FROM mcp_routing_observations WHERE id=ANY($1::uuid[])", [[badObservationId, pingOnlyId, otherPeerInterfaceId]])
await pool.query("UPDATE mcp_routing_observations SET loss_pct=5 WHERE id=$1", [observationId])
await attestRoutingFixture(observationId)
await assert.rejects(verifyRoutingObservationChecks(observationEvidence.observationChecks))
@@ -2589,9 +2602,10 @@ try {
const nativeRequests = ospfAnalysisNativeRequests - requestsBeforeApply
assert.ok(nativeRequests <= 160, `One OSPF cost PATCH caused ${nativeRequests} native requests; full graph polling must not run inside each catalog callback`)
console.log(`✓ OSPF dispatch read budget: ${nativeRequests} native requests across three owners, including one PATCH; no nested full graph polling`)
await pool.query("UPDATE mcp_plans SET operation_version=8 WHERE id=$1", [prepared.planId])
const { rows: [version] } = await pool.query<{ operation_version: number }>("SELECT operation_version FROM mcp_plans WHERE id=$1", [prepared.planId])
await pool.query("UPDATE mcp_plans SET operation_version=$2 WHERE id=$1", [prepared.planId, version.operation_version - 1])
try { await assert.rejects(invokeMcpTool(graphPrincipal, "mm_operation_recovery_plan", { operationId: operation.id }), (error: { code?: string }) => error.code === "PLAN_EXPIRED") }
finally { await pool.query("UPDATE mcp_plans SET operation_version=9 WHERE id=$1", [prepared.planId]) }
finally { await pool.query("UPDATE mcp_plans SET operation_version=$2 WHERE id=$1", [prepared.planId, version.operation_version]) }
const oldInverse = await invokeMcpTool(graphPrincipal, "mm_operation_recovery_plan", { operationId: operation.id }) as { planId: string }
await approvePlan(oldInverse.planId, actor, true)
await pool.query("UPDATE mcp_plans SET operation_version=1 WHERE id=$1", [oldInverse.planId])
@@ -2755,8 +2769,8 @@ try {
await approvePlan(brokenEcmp.planId, actor, true)
loseEcmpGateway = true
const brokenOperation = await applyPlan(ecmpPrincipal, brokenEcmp.planId, randomUUID())
await awaitOperation(brokenOperation.id, "partial"); assert.equal(ecmpWrites, 6)
await applyPlan(ecmpPrincipal, brokenEcmp.planId, randomUUID()); await delay(30); assert.equal(ecmpWrites, 6)
await awaitOperation(brokenOperation.id, "partial"); assert.equal(ecmpWrites, 5)
await applyPlan(ecmpPrincipal, brokenEcmp.planId, randomUUID()); await delay(30); assert.equal(ecmpWrites, 5)
loseEcmpGateway = false; fixtureEcmpRoutes = structuredClone(ecmpBaseline)
const tableProtection = randomUUID()
await pool.query("INSERT INTO mcp_protections(id,object_ref,created_by) VALUES($1,$2::jsonb,'integration')", [tableProtection, JSON.stringify({ kind: "routing_table", id: "*main", serverId: 1, protection: "deny" })])
@@ -2786,7 +2800,13 @@ try {
assert.equal(state, "applied"); assert.equal(writes, 1); assert.equal(settings["rp-filter"], "loose")
assert.equal((await pool.query("SELECT state FROM mcp_operation_steps WHERE operation_id=$1", [operation.id])).rows[0].state, "verified")
console.log("✓ Human approval, write-ahead intent, single idempotent write and read-back verification")
const stale = await invokeMcpTool(principal, "mm_ip_settings_update_plan", { serverId: 1, fields: { "rp-filter": "strict" } }) as { planId: string }
const { detectMcpDrift } = await import("./workers.js")
const driftCount = async () => (await pool.query("SELECT count(*)::int AS n FROM mcp_drift WHERE operation_id=$1", [operation.id])).rows[0].n
const readsBeforeMatchingDrift = reconcileSettingsReads
await detectMcpDrift()
assert(reconcileSettingsReads > readsBeforeMatchingDrift)
assert.equal(await driftCount(), 0)
const stale = await invokeMcpTool(principal, "mm_ip_settings_update_plan", { serverId: 1, fields: { "rp-filter": "loose" } }) as { planId: string }
await approvePlan(stale.planId, actor, true)
settings["rp-filter"] = "no"
const conflict = await applyPlan(principal, stale.planId, "conflict-key")
@@ -2797,6 +2817,14 @@ try {
}
assert.equal(state, "rejected"); assert.equal(writes, 1)
console.log("✓ Changed snapshots reject execution before contacting the write endpoint")
const readsBeforeConfirmedDrift = reconcileSettingsReads
await detectMcpDrift()
assert(reconcileSettingsReads > readsBeforeConfirmedDrift)
assert.equal(await driftCount(), 1)
await detectMcpDrift()
assert.equal(await driftCount(), 1)
assert.equal(writes, 1)
console.log("✓ Drift detection reads the bound RouterOS source, records one confirmed incident and never replays writes")
const expiring = await invokeMcpTool(principal, "mm_ip_settings_update_plan", { serverId: 1, fields: { "rp-filter": "loose" } }) as { planId: string }
const sealedEvidence = (await pool.query("SELECT evidence FROM mcp_plans WHERE id=$1", [expiring.planId])).rows[0].evidence
const expiringEvidence = JSON.parse(await decryptSecret(sealedEvidence.sealed))
@@ -2855,6 +2883,7 @@ try {
process.env.MCP_PUBLIC_URL = endpoint
app = await buildApp({ startScheduler: false })
await app.listen({ port: appPort, host: "127.0.0.1" })
await (await import("../../test/users-profile-permissions.js")).verifyUsersProfilePermissions(app)
await (await import("../../test/mcp-backup-download.js")).verifyMcpBackupDownload(actor,app)
await (await import("../../test/mcp-database-backup.js")).verifyMcpDatabaseBackup(actor,app)
const fixtureScheduler = await import("../../services/scheduler-running.js")
@@ -2889,6 +2918,55 @@ try {
assert.ok(!result.isError)
console.log("✓ Official SDK HTTP initialize, filtered tools/list and tools/call")
} finally { await client.close() }
{
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({
...input, name: "Logs follow maintenance", permissions: ["mm:servers:read"],
scope: { serverIds: [1], operationIds: ["logs.follow"], allowGlobal: false },
}), actor)
const follower = new Client({ name: "logs-follow-maintenance", version: "1.0.0" })
const { sharedDatabaseGate } = await import("../../services/shared-database-gate.js")
const original = pool.query
let arrived!: () => void, release!: () => void
const reading = new Promise<void>(resolve => { arrived = resolve })
const held = new Promise<void>(resolve => { release = resolve })
try {
await follower.connect(new StreamableHTTPClientTransport(new URL(endpoint), { requestInit: { headers: { Authorization: `Bearer ${access.token}` } } }))
pool.query = (async (...args: Parameters<typeof original>) => {
const text = typeof args[0] === "string" ? args[0] : args[0]?.text
const result = await Reflect.apply(original, pool, args)
if (typeof text === "string" && text.startsWith("SELECT GREATEST(COALESCE(max(id),0)")) {
arrived()
await held
}
return result
}) as typeof original
const following = follower.callTool({ name: "mm_logs_follow", arguments: { serverId: 1, waitSeconds: 30 } })
try {
const observation = new AbortController()
try {
await Promise.race([reading, following.then(() => { throw new Error("Follow finished before its stream query") }),
delay(5000, undefined, { signal: observation.signal }).then(() => { throw new Error("Follow stream query was not observed") })])
} finally { observation.abort() }
let exclusiveEntered = false
const maintenance = sharedDatabaseGate.exclusive(async () => { exclusiveEntered = true }, 5000)
try {
await delay(25)
assert.equal(exclusiveEntered, false, "maintenance must wait for the pending database read")
} finally {
release()
await maintenance
}
const result = await following
assert.equal(result.isError, true)
assert.match(JSON.stringify(result), /RESET_REQUIRED/)
assert.doesNotMatch(JSON.stringify(result), /"cursor"/)
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_read_leases WHERE connection_id=$1", [access.connection.id])).rows[0].count, 0)
} finally { release(); await following.catch(() => undefined) }
} finally { pool.query = original; await follower.close() }
}
const logsSdkAcceptance = await import("./logs-follow-sdk-cancellation-acceptance.js")
await logsSdkAcceptance.verifyLogsFollowSdkCancellation(app, endpoint, actor)
await logsSdkAcceptance.verifyLogsScopedSdkReads(app, endpoint, actor)
const stdio = new Client({ name: "stdio-integration", version: "1.0.0" })
try {
await stdio.connect(new StdioClientTransport({ command: process.execPath, args: ["--import", "tsx", fileURLToPath(new URL("../../scripts/mcp-stdio.ts", import.meta.url))], env: { MCP_URL: endpoint, MCP_TOKEN: rotated.token }, stderr: "pipe" }))
@@ -2907,17 +2985,41 @@ try {
await (await import("../../test/mcp-host-files.js")).verifyMcpHostFiles(actor,app)
const readinessAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Actual readiness handshake", scope: { ...input.scope, operationIds: ["servers.list"] } }), actor)
const readinessHeaders = { authorization: "Bearer " + jwt }
const simulatorHeaders = { authorization: "Bearer " + await new SignJWT({ sub: "policy-preview-admin", apps: ["mm"], permissions: ["mm:mcp:admin"] })
.setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setExpirationTime("1h").sign(new TextEncoder().encode(env.jwtSecret)) }
const policyPreview = (payload: Record<string, unknown>) => app!.inject({ method: "POST", url: "/api/mcp/policy/preview", headers: simulatorHeaders, payload })
const simulatedServerRead = { connectionId: readinessAccess.connection.id, operationId: "servers.list", target: { scope: "servers", serverIds: [1] } }
const simulation = await policyPreview(simulatedServerRead)
assert.equal(simulation.statusCode, 200, simulation.body)
assert.equal(simulation.json().result.decision, "allowed", "an administrator simulates the stored owner's rights, not the administrator's rights")
assert.equal(simulation.json().executionAvailable, false)
assert.equal((await policyPreview({ ...simulatedServerRead, ownerPermissions: ["mm:settings:admin"] })).statusCode, 400)
const ready = await app.inject({ method: "POST", url: `/api/mcp/connections/${readinessAccess.connection.id}/check`, headers: readinessHeaders, payload: {} })
assert.equal(ready.statusCode, 200, ready.body)
assert.equal(ready.json().ok, true, ready.body); assert.equal(ready.json().checks.protocolHandshake, true)
assert.equal(ready.json().protocolProbe.status, "passed"); assert.equal(ready.json().protocolProbe.clientCredentialsVerified, false)
assert.equal(ready.json().protocolProbe.capabilitiesRead, "skipped")
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_credentials WHERE connection_id=$1 AND client_id='mcp-readiness-probe'", [readinessAccess.connection.id])).rows[0].n, 0)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_grants WHERE connection_id=$1", [readinessAccess.connection.id])).rows[0].n, 0)
const duplicateReady = await app.inject({ method: "POST", url: `/api/mcp/connections/${readinessAccess.connection.id}/check`, headers: readinessHeaders, payload: {} })
assert.equal(duplicateReady.statusCode, 429)
await setConnectionStatus(readinessAccess.connection.id, "paused", actor)
const pausedSimulation = await policyPreview(simulatedServerRead)
assert.equal(pausedSimulation.statusCode, 200, pausedSimulation.body)
assert.equal(pausedSimulation.json().result.decision, "denied")
assert.ok(pausedSimulation.json().result.reasons.some((reason: { code: string }) => reason.code === "CONNECTION_INACTIVE"))
const pausedReady = await app.inject({ method: "POST", url: `/api/mcp/connections/${readinessAccess.connection.id}/check`, headers: readinessHeaders, payload: {} })
assert.equal(pausedReady.statusCode, 200); assert.equal(pausedReady.json().protocolProbe.status, "skipped")
const readableAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Actual readiness capability read", scope: { ...input.scope, operationIds: ["servers.list", "capabilities.read"] } }), actor)
const readable = await app.inject({ method: "POST", url: `/api/mcp/connections/${readableAccess.connection.id}/check`, headers: readinessHeaders, payload: {} })
assert.equal(readable.statusCode, 200, readable.body)
assert.equal(readable.json().ok, true, readable.body)
assert.equal(readable.json().protocolProbe.capabilitiesRead, "passed")
assert.equal(readable.json().protocolProbe.credentialType, "temporary-capabilities-read")
const readinessReads = (await pool.query("SELECT tool FROM mcp_requests WHERE connection_id=$1 AND tool NOT LIKE 'protocol:%'", [readableAccess.connection.id])).rows
assert.deepEqual(readinessReads.map(row => row.tool), ["mm_capabilities_get"])
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_credentials WHERE connection_id=$1 AND client_id='mcp-readiness-probe'", [readableAccess.connection.id])).rows[0].n, 0)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_grants WHERE connection_id=$1", [readableAccess.connection.id])).rows[0].n, 0)
const emptyGrantAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Readiness has no operation authority" }), actor)
const { withMcpProbeCredential } = await import("./readiness.js")
let endedProbeToken = ""
@@ -2933,7 +3035,7 @@ try {
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_grants WHERE connection_id=$1", [emptyGrantAccess.connection.id])).rows[0].n, 0)
const readinessAudit = (await pool.query("SELECT payload FROM mcp_audit WHERE connection_id=$1", [emptyGrantAccess.connection.id])).rows
assert.ok(!JSON.stringify(readinessAudit).includes(endedProbeToken))
console.log("✓ Actual readiness initialize/tools-list over public HTTP: temporary empty grant, no tool authority, cleanup on success/error, cooldown and paused precheck skip")
console.log("✓ Actual readiness initialize/tools-list and optional capabilities read over public HTTP: narrow grant, cleanup on success/error, cooldown and paused precheck skip")
const deviceConnection = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Web interfaces", scope: { ...input.scope, operationIds: ["ros.bridge.list", "ros.bridge.create"] } }), actor)
const deviceHeaders = { authorization: "Bearer " + jwt }
const deviceCatalog = await app.inject({ url: "/api/mcp/devices/catalog?connectionId=" + deviceConnection.connection.id, headers: deviceHeaders })
@@ -2964,12 +3066,6 @@ try {
assert.equal((await tokenRequest(refreshPayload)).statusCode, 400)
await assert.rejects(authenticateMcpToken(refreshed.json().access_token))
console.log("✓ OAuth consent, PKCE, resource binding, one-use code, refresh rotation and reuse-family revocation")
const { detectMcpDrift } = await import("./workers.js")
await detectMcpDrift()
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_drift WHERE operation_id=$1", [operation.id])).rows[0].n, 1)
await detectMcpDrift()
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_drift WHERE operation_id=$1", [operation.id])).rows[0].n, 1)
console.log("✓ Drift detection records a single open incident without replaying writes")
const currentPrincipal = await authenticateMcpToken(rotated.token)
await assert.rejects(invokeMcpTool(currentPrincipal, "mm_operation_recovery_plan", { operationId: operation.id }), error => error instanceof Error && "code" in error && error.code === "STATE_CONFLICT")
const beforeRecovery = settings["rp-filter"]
@@ -0,0 +1,6 @@
import { McpError } from "./security.js"
export function logsFollowCancellation(maintenance?: AbortSignal, caller?: AbortSignal): McpError | undefined {
if (maintenance?.aborted) return maintenance.reason
if (caller?.aborted) return new McpError("READ_CANCELLED", "Ожидание новых событий отменено", 409)
}
@@ -0,0 +1,251 @@
import assert from "node:assert/strict"
import { createHash, randomUUID } from "node:crypto"
import type { IncomingMessage, ServerResponse } from "node:http"
import { setTimeout as delay } from "node:timers/promises"
import type { FastifyInstance } from "fastify"
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"
import { SignJWT } from "jose"
import type { LogEntry } from "@mmapp/contracts"
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
import { pool } from "../../db/index.js"
import { env } from "../../config.js"
import type { AuthUser } from "../../lib/permissions.js"
import { createMcpConnection, setConnectionStatus } from "./store.js"
export async function verifyLogsScopedSdkReads(app: FastifyInstance, endpoint: string, actor: AuthUser) {
const historicalId = 93, hiddenId = 94, marker = `mcp-scoped-logs-${randomUUID()}`
const from = "2001-01-01T00:00:00Z", to = "2001-01-01T01:00:00Z"
const client = new Client({ name: "logs-scoped-sdk-reads", version: "1.0.0" })
try {
await pool.query("INSERT INTO servers(id,name,host,enabled,username,password) VALUES($1,$2,'127.0.0.1',false,'fixture','fixture')", [historicalId, marker])
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({
name: marker, mode: "observer", permissions: ["mm:servers:read"],
expiresAt: new Date(Math.min(actor.expiresAt ?? Infinity, Date.now() + 600_000)).toISOString(),
scope: { serverIds: [1, historicalId], operationIds: ["logs.get", "logs.context", "logs.summary", "logs.export"], allowGlobal: false },
}), actor)
const { rows } = await pool.query<{ id: string; serverId: number; index: string }>(`
INSERT INTO router_log_entries(server_id,server_name,host,router_id,raw_time,received_at,effective_at,topics,level,message,generation)
SELECT server_id,$1,'127.0.0.1',n::text,'00:30:00',now(),$2::timestamptz+(30+n)*interval '1 minute',ARRAY['system'],'info',
CASE WHEN server_id=$4 THEN $1 || ' hidden-row' ELSE $1 END,0
FROM (SELECT 1 AS server_id,n FROM generate_series(-12,12) AS n UNION ALL SELECT $3,0 UNION ALL SELECT $4,0) AS fixture
RETURNING id::text,server_id AS "serverId",router_id AS "index"`, [marker, from, historicalId, hiddenId])
await pool.query("INSERT INTO router_log_collection_state(server_id,server_name,host) SELECT id,$1,'127.0.0.1' FROM unnest($2::int[]) AS id", [marker, [historicalId, hiddenId]])
assert.equal((await pool.query("DELETE FROM servers WHERE id=$1 AND name=$2", [historicalId, marker])).rowCount, 1)
await client.connect(new StreamableHTTPClientTransport(new URL(endpoint), {
requestInit: { headers: { Authorization: `Bearer ${access.token}` } },
}))
const idAt = (serverId: number, index: number) => {
const row = rows.find(item => item.serverId === serverId && Number(item.index) === index)
assert.ok(row)
return row.id
}
const call = (name: string, args: Record<string, unknown>) => client.callTool({ name, arguments: args })
const resultOf = async <T>(name: string, args: Record<string, unknown>) => {
const response = await call(name, args)
assert.ok(!response.isError)
return (response.structuredContent as { result: T }).result
}
const id = idAt(1, 0)
assert.equal((await resultOf<LogEntry>("mm_logs_get", { serverId: 1, id })).id, id)
type LogContext = { entry: LogEntry; before: LogEntry[]; after: LogEntry[] }
const context = await resultOf<LogContext>("mm_logs_context_get", { serverId: 1, id })
assert.equal(context.entry.id, id)
assert.deepEqual(context.before.map(row => row.id), Array.from({ length: 10 }, (_, i) => idAt(1, i - 10)))
assert.deepEqual(context.after.map(row => row.id), Array.from({ length: 10 }, (_, i) => idAt(1, i + 1)))
const historical = { serverId: historicalId, id: idAt(historicalId, 0) }
assert.equal((await resultOf<LogEntry>("mm_logs_get", historical)).serverId, historicalId)
const historicalContext = await resultOf<LogContext>("mm_logs_context_get", historical)
assert.equal(historicalContext.entry.id, historical.id)
assert.deepEqual([...historicalContext.before, ...historicalContext.after], [])
for (const name of ["mm_logs_get", "mm_logs_context_get"]) {
const foreignId = idAt(hiddenId, 0)
const foreign = await call(name, { serverId: 1, id: foreignId })
assert.equal(foreign.isError, true)
assert.match(JSON.stringify(foreign), /NOT_FOUND/)
assert.doesNotMatch(JSON.stringify(foreign), new RegExp(marker))
const outOfScope = await call(name, { serverId: hiddenId, id: foreignId })
assert.equal(outOfScope.isError, true)
assert.match(JSON.stringify(outOfScope), /SERVER_DENIED/)
}
const summary = await resultOf<{ total: number; levels: { info: number }; states: { serverId: number }[] }>(
"mm_logs_summary_get", { from, to, q: marker },
)
assert.equal(summary.total, 26)
assert.equal(summary.levels.info, 26)
assert.ok(summary.states.some(state => state.serverId === historicalId))
assert.ok(summary.states.every(state => state.serverId === 1 || state.serverId === historicalId))
assert.ok(!("settings" in summary))
console.log("✓ MCP logs SDK: scoped entries, same-server context and historical summary")
const portalJwt = (subject: string, permissions: string[]) =>
new SignJWT({ sub: subject, apps: ["mm"], permissions })
.setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setExpirationTime("5m")
.sign(new TextEncoder().encode(env.jwtSecret))
const ownerToken = await portalJwt(actor.id, actor.permissions)
const otherToken = await portalJwt(`${actor.id}-other`, ["mm:mcp:read", "mm:servers:read"])
const restrictedToken = await portalJwt(actor.id, ["mm:mcp:read"])
type Export = { artifactId: string; downloadPath: string; expiresInSeconds: number; rows: number; sha256: string }
const exportLogs = (format: "csv" | "json") => resultOf<Export>("mm_logs_export", { from, to, q: marker, format })
const download = (url: string, token?: string) =>
app.inject({ url, headers: token ? { authorization: `Bearer ${token}` } : {} })
const csv = await exportLogs("csv")
assert.equal(csv.rows, 26)
assert.equal(csv.expiresInSeconds, 900)
assert.equal(csv.downloadPath, `/api/mcp/artifacts/${csv.artifactId}`)
const response = await download(csv.downloadPath, ownerToken)
assert.equal(response.statusCode, 200)
assert.equal(response.headers["cache-control"], "private, no-store")
assert.match(String(response.headers["content-type"]), /^text\/csv/)
assert.equal(createHash("sha256").update(response.rawPayload).digest("hex"), csv.sha256)
assert.ok(!response.body.includes("hidden-row"))
assert.equal((await download(csv.downloadPath)).statusCode, 401)
assert.equal((await download(csv.downloadPath, access.token)).statusCode, 401)
assert.equal((await download(csv.downloadPath, otherToken)).statusCode, 404)
assert.equal((await download(csv.downloadPath, restrictedToken)).statusCode, 403)
const json = await exportLogs("json")
await pool.query("UPDATE mcp_artifacts SET expires_at=now()-interval '1 second' WHERE id=$1 AND connection_id=$2", [json.artifactId, access.connection.id])
assert.equal((await download(json.downloadPath, ownerToken)).statusCode, 404)
await setConnectionStatus(access.connection.id, "revoked", actor)
assert.equal((await download(csv.downloadPath, ownerToken)).statusCode, 403)
console.log("✓ MCP logs export: CSV owner download, scope, non-cacheable response, expiry and revocation")
} finally {
await client.close()
await pool.query("DELETE FROM router_log_entries WHERE message=ANY($1::text[])", [[marker, `${marker} hidden-row`]])
await pool.query("DELETE FROM router_log_collection_state WHERE server_id=ANY($1::int[]) AND server_name=$2", [[historicalId, hiddenId], marker])
await pool.query("DELETE FROM servers WHERE id=$1 AND name=$2", [historicalId, marker])
}
}
export async function verifyLogsFollowSdkCancellation(app: FastifyInstance, endpoint: string, actor: AuthUser) {
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({
name: "Logs follow SDK cancellation", mode: "approval", permissions: ["mm:servers:read"],
expiresAt: new Date(Math.min(actor.expiresAt ?? Infinity, Date.now() + 600_000)).toISOString(),
scope: { serverIds: [1], operationIds: ["logs.follow", "logs.query"], allowGlobal: false },
}), actor)
const client = new Client({ name: "logs-follow-sdk-cancellation", version: "1.0.0" }, {
versionNegotiation: { mode: { pin: "2026-07-28" } },
})
const caller = new AbortController(), bound = new AbortController()
const deadline = delay(5000, undefined, { signal: bound.signal }).then(() => { throw new Error("SDK cancellation fixture deadline") })
void deadline.catch(() => undefined)
let entered!: () => void, release!: () => void, closed!: () => void
const enteredRead = new Promise<void>(resolve => { entered = resolve })
const holdRead = new Promise<void>(resolve => { release = resolve })
const responseClosed = new Promise<void>(resolve => { closed = resolve })
let readCount = 0
const originalQuery = pool.query
const observeClose = (request: IncomingMessage, response: ServerResponse) => {
if (request.method === "POST" && request.url === "/mcp") {
response.once("close", () => { if (!response.writableFinished) closed() })
}
}
try {
await client.connect(new StreamableHTTPClientTransport(new URL(endpoint), {
requestInit: { headers: { Authorization: `Bearer ${access.token}` } },
}))
app.server.on("request", observeClose)
pool.query = (async (...args: unknown[]) => {
const query = args[0]
const sql = typeof query === "object" && query !== null && "text" in query ? query.text : query
const result = await Reflect.apply(originalQuery, pool, args)
if (typeof sql === "string" && sql.startsWith("SELECT GREATEST(COALESCE(max(id),0)")) {
readCount++
entered()
await holdRead
}
return result
}) as typeof originalQuery
const follow = client.callTool({ name: "mm_logs_follow", arguments: { serverId: 1, waitSeconds: 30 } }, { signal: caller.signal })
const refused = assert.rejects(follow)
try {
await Promise.race([enteredRead, deadline])
caller.abort()
await Promise.race([Promise.all([refused, responseClosed]), deadline])
const live = await pool.query("SELECT count(*)::int AS count FROM mcp_read_leases WHERE connection_id=$1", [access.connection.id])
assert.equal(live.rows[0].count, 1, "caller cancellation must wait for the pending read before releasing the lease")
release()
for (;;) {
const { rows: [audit] } = await pool.query(
"SELECT status,error_code,result FROM mcp_requests WHERE connection_id=$1 AND tool='mm_logs_follow'", [access.connection.id],
)
const protocol = await pool.query("SELECT count(*)::int AS count FROM mcp_requests WHERE connection_id=$1 AND tool='protocol:tools/call' AND finished_at IS NOT NULL", [access.connection.id])
if (audit && audit.status !== "running" && protocol.rows[0].count === 1) {
assert.equal(audit.status, "error")
assert.equal(audit.error_code, "READ_CANCELLED")
assert.doesNotMatch(JSON.stringify(audit.result), /"cursor"/)
break
}
await Promise.race([delay(25), deadline])
}
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_read_leases WHERE connection_id=$1", [access.connection.id])).rows[0].count, 0)
assert.equal(readCount, 1, "cancelled follow does not dispatch a second read")
} finally {
caller.abort()
release()
await refused.catch(() => undefined)
}
pool.query = originalQuery
const from = "2000-01-01T00:00:00Z", to = "2000-01-01T00:30:00Z"
const followOnce = (filters: { from?: string; cursor?: string }) => client.callTool({
name: "mm_logs_follow", arguments: { serverId: 1, waitSeconds: 0, to, ...filters },
})
const initial = await followOnce({ from })
assert.ok(!initial.isError)
const cursor = (initial.structuredContent as { result?: { cursor?: unknown } } | undefined)?.result?.cursor
assert.ok(typeof cursor === "string" && cursor.length > 0)
assert.ok(!(await followOnce({ from, cursor })).isError)
assert.ok(!(await followOnce({ cursor })).isError, "omitting from must resume the historical cursor's period")
const changed = await followOnce({ from: "2000-01-01T00:01:00Z", cursor })
assert.equal(changed.isError, true)
assert.match(JSON.stringify(changed), /RESET_REQUIRED/)
assert.doesNotMatch(JSON.stringify(changed), /"cursor"/)
console.log("✓ MCP logs follow: historical cursor resumes, changed from requires reset")
const marker = `mcp-logs-cursor-${randomUUID()}`
const readFollowPage = async (options: { cursor?: string; limit?: number }) => {
const result = await client.callTool({ name: "mm_logs_follow", arguments: { serverId: 1, from, to, q: marker, waitSeconds: 0, ...options } })
assert.ok(!result.isError)
return (result.structuredContent as { result: { entries: { id: string }[]; cursor: string } }).result
}
const start = await readFollowPage({})
assert.deepEqual(start.entries, [])
try {
await pool.query(`INSERT INTO router_log_entries(server_id,server_name,host,router_id,raw_time,received_at,effective_at,topics,level,message,generation)
SELECT 1,'cursor fixture','127.0.0.1','fixture','00:02:00',now(),$1::timestamptz+n*interval '2 minutes',ARRAY['system'],'info',$2,0
FROM generate_series(1,2) AS n`, [from, marker])
const defaultFollow = await readFollowPage({ cursor: start.cursor })
assert.equal(defaultFollow.entries.length, 2)
const firstFollow = await readFollowPage({ cursor: start.cursor, limit: 1 })
const secondFollow = await readFollowPage({ cursor: firstFollow.cursor, limit: 1 })
assert.equal(firstFollow.entries.length, 1)
assert.equal(secondFollow.entries.length, 1)
assert.deepEqual([...firstFollow.entries, ...secondFollow.entries].map(row => row.id), defaultFollow.entries.map(row => row.id))
assert.deepEqual((await readFollowPage({ cursor: secondFollow.cursor, limit: 1 })).entries, [])
console.log("✓ MCP logs follow: requested batch limit and lossless cursor progression")
const query = (filters: { from?: string; cursor?: string }) => client.callTool({
name: "mm_logs_query", arguments: { serverId: 1, q: marker, limit: 1, to, ...filters },
})
type Page = { entries: { id: string }[]; nextCursor: string | null }
const first = await query({ from })
assert.ok(!first.isError)
const firstPage = (first.structuredContent as { result: Page }).result
assert.equal(firstPage.entries.length, 1)
assert.ok(firstPage.nextCursor)
const next = await query({ cursor: firstPage.nextCursor })
assert.ok(!next.isError)
const nextPage = (next.structuredContent as { result: Page }).result
assert.equal(nextPage.entries.length, 1)
assert.notEqual(nextPage.entries[0].id, firstPage.entries[0].id)
assert.equal(nextPage.nextCursor, null)
console.log("✓ MCP logs query: historical pagination resumes without resending from")
} finally {
await pool.query("DELETE FROM router_log_entries WHERE server_id=1 AND message=$1", [marker])
}
} finally {
bound.abort()
pool.query = originalQuery
app.server.off("request", observeClose)
await client.close()
}
}
+12 -2
View File
@@ -41,7 +41,7 @@ export async function registerManagementChannel(raw: unknown, actor: AuthUser) {
})
return { id, serverId: input.serverId, expiresAt: input.expiresAt, verification: "identity-and-reachability", independence: "human-attestation" }
}
export async function verifyManagementChannel(serverId: number, requiredId?: string) {
async function verifiedManagementChannel(serverId: number, requiredId?: string) {
const { rows: [row] } = await pool.query("SELECT * FROM mcp_management_channels WHERE server_id=$1 AND revoked_at IS NULL AND expires_at>now()", [serverId])
if (!row || requiredId && row.id !== requiredId || row.generation !== await accessGeneration()) throw new McpError("APPROVAL_REQUIRED", "Нет актуального проверенного независимого канала управления", 409)
const config = JSON.parse(await decryptSecret(row.config_sealed)) as MikrotikConnectParams
@@ -50,7 +50,17 @@ export async function verifyManagementChannel(serverId: number, requiredId?: str
if (hashes.some(value => value !== row.identity_hash)) throw new McpError("STATE_CONFLICT", "Идентичность основного или management-канала изменилась", 409)
const changed = await pool.query("UPDATE mcp_management_channels SET verified_at=now() WHERE id=$1 AND revoked_at IS NULL AND expires_at>now() RETURNING id", [row.id])
if (!changed.rowCount) throw new McpError("APPROVAL_REQUIRED", "Допуск management-канала был отозван во время проверки", 409)
return { channelId: row.id as string, serverId, expiresAt: row.expires_at.toISOString() as string }
return { channelId: row.id as string, serverId, expiresAt: row.expires_at.toISOString() as string, host: config.host }
}
export async function verifyManagementChannel(serverId: number, requiredId?: string) {
const { host: _host, ...receipt } = await verifiedManagementChannel(serverId, requiredId)
return receipt
}
/** Internal VLAN preparation uses the verified endpoint to trace its actual
* address/interface. It is not accepted from tool input or returned in the
* public channel receipt. */
export async function verifyBridgeManagementChannel(serverId: number, requiredId?: string) {
return verifiedManagementChannel(serverId, requiredId)
}
export async function managementEvidence(serverIds: number[]) {
try { return { requiresHuman: false, managementChannels: await Promise.all([...new Set(serverIds)].map(serverId => verifyManagementChannel(serverId))) } }
@@ -6,6 +6,7 @@ import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
import { pool } from "../../db/index.js"
import type { AuthUser } from "../../lib/permissions.js"
import { managedComment } from "../../managed-markers.js"
import { updateServer } from "../servers/service/servers-service.js"
import type { ChangeEvidence } from "./action-types.js"
import { findAction } from "./actions.js"
import { readRosSnapshot } from "./resources.js"
@@ -18,13 +19,16 @@ import { authenticateMcpToken, createMcpConnection, setConnectionStatus } from "
export async function acceptNativeConservation(actor: AuthUser) {
const serverId = 198
const filterPath = "/ip/firewall/filter", rulePath = "/routing/rule"
const routePaths = ["/ip/route", "/ipv6/route"], settingsPaths = ["/ip/settings", "/ipv6/settings"]
const base = () => ["a", "b", "c"].map(id => ({ ".id": `*${id}`, chain: "fixture", action: "accept", comment: id, disabled: "true" }))
const ruleBase = () => ["a", "b", "c"].map(id => ({ ".id": `*${id}`, action: "lookup", table: "main", comment: id, disabled: "true" }))
let catalogs: Record<string, Record<string, string>[]> = {}, mode = "normal", writes = 0, next = 0, polls = 0
let connectionId: string | undefined
let afterIntent: (() => Promise<void>) | undefined, afterVerified: (() => Promise<void>) | undefined
let afterRouteRead: (() => Promise<void>) | undefined
const query = pool.query.bind(pool)
const commands: Array<{ method: string; path: string }> = []
const readPaths = new Set<string>()
const reset = () => {
mode = "normal"
catalogs = { [filterPath]: base(), [rulePath]: ruleBase(), "/routing/table": [{ ".id": "*main", name: "main", fib: "true", disabled: "false" }], "/ip/settings": [{ "rp-filter": "no", "ipv4-multipath-hash-policy": "l3" }], "/ip/route": [] }
@@ -33,8 +37,18 @@ export async function acceptNativeConservation(actor: AuthUser) {
const router = createServer(async (req, res) => {
const path = decodeURIComponent((req.url ?? "").replace(/^\/rest/, ""))
res.setHeader("content-type", "application/json")
if (req.method === "GET") { polls++; res.end(JSON.stringify(catalogs[path] ?? [])); return }
const parent = [filterPath, rulePath, "/ip/route", "/ip/settings"].find(value => path === value || path.startsWith(`${value}/`))
if (req.method === "GET") {
polls++; readPaths.add(path)
const body = JSON.stringify(catalogs[path] ?? [])
if (path === "/ip/route" && afterRouteRead) {
const hook = afterRouteRead
afterRouteRead = undefined
await hook()
}
res.end(body)
return
}
const parent = [filterPath, rulePath, ...routePaths, ...settingsPaths].find(value => path === value || path.startsWith(`${value}/`))
if (!parent || !["PUT", "PATCH", "DELETE"].includes(req.method ?? "")) { res.statusCode = 404; res.end("{}"); return }
let text = ""; for await (const chunk of req) text += chunk
const fields = text ? JSON.parse(text) as Record<string, string> : {}
@@ -42,11 +56,11 @@ export async function acceptNativeConservation(actor: AuthUser) {
writes++; commands.push({ method: req.method!, path })
if (mode !== "ignored") {
if (req.method === "PUT") rows.push({ ".id": id, ...fields })
if (req.method === "PATCH") Object.assign(parent === "/ip/settings" ? rows[0] : rows.find(row => row[".id"] === id)!, fields)
if (req.method === "PATCH") Object.assign(settingsPaths.includes(parent) ? rows[0] : rows.find(row => row[".id"] === id)!, fields)
if (req.method === "DELETE") catalogs[parent] = rows.filter(row => row[".id"] !== id)
}
const after = catalogs[parent]
if (parent === "/ip/route") for (const row of after) row.active = Number(row.distance) === Math.min(...after.map(item => Number(item.distance))) ? "true" : "false"
if (routePaths.includes(parent)) for (const row of after) row.active = Number(row.distance) === Math.min(...after.map(item => Number(item.distance))) ? "true" : "false"
if (mode === "two-new") after.push({ ".id": `*extra${next}`, ...fields })
if (mode === "collateral-update") after.find(row => row[".id"] === "*b")!.comment = "external"
if (mode === "collateral-remove") catalogs[parent] = after.filter(row => row[".id"] !== "*b")
@@ -80,7 +94,7 @@ export async function acceptNativeConservation(actor: AuthUser) {
try {
assert.equal((await query("SELECT count(*)::int AS n FROM servers WHERE id=$1", [serverId])).rows[0].n, 0)
await query("INSERT INTO servers(id,name,host,port,use_ssl,password) VALUES($1,'native-conservation','127.0.0.1',$2,false,'fixture-only')", [serverId, (router.address() as { port: number }).port])
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Native conservation", mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read", "mm:network:write"], scope: { serverIds: [serverId], operationIds: ["ros.ip_filter.create", "ros.ip_filter.update", "ros.ip_filter.delete", "ros.routing_rule.update", "ros.ipv4_route.update", "ros.ip_settings.update", "routing.ecmp.update", "routing.rpf.update", "operations.recover", "operations.reconcile"], allowGlobal: false }, limits: { callsPerMinute: 1000, mutationsPerHour: 1000 } }), actor)
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Native conservation", mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read", "mm:network:write"], scope: { serverIds: [serverId], operationIds: ["ros.ip_filter.create", "ros.ip_filter.update", "ros.ip_filter.delete", "ros.routing_rule.update", "ros.ipv4_route.update", "ros.ip_settings.update", "routing.ecmp.update", "routing.rpf.update", "operations.recover", "operations.reconcile", "operations.read"], allowGlobal: false }, limits: { callsPerMinute: 1000, mutationsPerHour: 1000 } }), actor)
connectionId = access.connection.id
let principal = await authenticateMcpToken(access.token)
const prepare = (action: "create" | "update" | "delete", resource = "ip_filter") => invokeMcpTool(principal, `mm_${resource}_${action}_plan`, { serverId, ...(action === "create" ? {} : { id: "*a" }), fields: action === "create" ? { chain: "fixture", action: "accept", comment: "new", disabled: true } : action === "update" ? { comment: "changed" } : {} }) as Promise<{ planId: string }>
@@ -204,13 +218,233 @@ export async function acceptNativeConservation(actor: AuthUser) {
const routeBaseline = [0, 1].map(index => ({ ".id": `*route${index}`, "dst-address": "192.0.2.0/24", "routing-table": "main", gateway: `198.18.0.${index + 1}`, "immediate-gw": `198.18.0.${index + 1}%fixture`, scope: "0", distance: String((index + 1) * 10), active: index === 0 ? "true" : "false", disabled: "false", dynamic: "false", comment: managedComment("conservation") }))
catalogs["/ip/route"] = structuredClone(routeBaseline)
const originalSettings = structuredClone(catalogs["/ip/settings"])
const equal = await invokeMcpTool(principal, "mm_ecmp_plan", { serverId, family: "ipv4", routeIds: ["*route0", "*route1"], distance: 10, hashPolicy: "l4" }) as { planId: string }
const ecmpArgs = { serverId, family: "ipv4", routeIds: ["*route0", "*route1"], distance: 10, hashPolicy: "l4" }
const beforeEcmp = writes
const observer = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "ECMP interface-only preflight", mode: "observer", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read"], scope: { serverIds: [serverId], operationIds: ["routing.ecmp.preflight.read"], allowGlobal: false } }), actor)
try {
const reader = await authenticateMcpToken(observer.token)
const { distance, hashPolicy, ...preflightInput } = ecmpArgs
catalogs["/ip/firewall/connection/tracking"] = [{ enabled: "auto" }]
catalogs["/ip/route"] = routeBaseline.map((row, index) => ({ ...row, gateway: `gre${index}`, "immediate-gw": `gre${index}` }))
catalogs["/interface"] = [0, 1].map(index => ({ ".id": `*gre${index}`, name: `gre${index}` }))
const before = polls
const result = await invokeMcpTool(reader, "mm_ecmp_preflight_get", preflightInput) as { configurationObserved: boolean; networkValidation: string; sources: { interfaces: { count: number } } }
assert.equal(result.configurationObserved, true)
assert.equal(result.networkValidation, "not-performed")
assert.equal(result.sources.interfaces.count, 2)
assert.equal(polls - before, 11)
assert.equal(writes, beforeEcmp)
} finally {
await query("DELETE FROM mcp_connections WHERE id=$1", [observer.connection.id])
catalogs["/ip/route"] = structuredClone(routeBaseline)
}
catalogs["/ip/route"] = routeBaseline.map((row, index) => ({ ...row, gateway: `gre${index}`, "immediate-gw": `gre${index}` }))
const namedHopPlan = await invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs) as { planId: string; changes: unknown }
assert.ok(!JSON.stringify(namedHopPlan.changes).includes("nextHopInterface"))
const inventory = structuredClone(catalogs["/interface"])
afterIntent = async () => { catalogs["/interface"][0][".id"] = "*replacement" }
const replacedHop = await apply(namedHopPlan.planId)
assert.equal(afterIntent, undefined)
assert.equal(replacedHop.state, "rejected")
assert.equal(replacedHop.error_code, "STATE_CONFLICT")
assert.equal(writes, beforeEcmp)
catalogs["/interface"] = inventory
catalogs["/ip/route"] = structuredClone(routeBaseline)
console.log("ECMP interface-only next-hop: same-name replacement after intent refused before PATCH; native identity stays private")
afterRouteRead = async () => { catalogs["/ip/route"][1].distance = "25" }
await assert.rejects(invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs), { code: "STATE_CONFLICT" })
assert.equal(afterRouteRead, undefined)
catalogs["/ip/route"] = structuredClone(routeBaseline)
afterRouteRead = async () => {
await query("UPDATE servers SET password='source-aba-fixture' WHERE id=$1", [serverId])
await query("UPDATE servers SET password='fixture-only' WHERE id=$1", [serverId])
}
await assert.rejects(invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs), { code: "STATE_CONFLICT" })
assert.equal(afterRouteRead, undefined)
assert.equal(writes, beforeEcmp)
catalogs["/ip/route"][1]["immediate-gw"] = routeBaseline[0]["immediate-gw"]
await assert.rejects(invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs), { code: "STATE_CONFLICT" })
assert.equal(writes, beforeEcmp)
catalogs["/ip/route"] = structuredClone(routeBaseline)
const fibTable = catalogs["/routing/table"]
for (const rows of [
[{ ...fibTable[0], fib: "false" }],
[{ ...fibTable[0], fib: "" }],
[{ ".id": "*main", name: "main" }],
[{ ...fibTable[0], disabled: "true" }],
[{ ...fibTable[0], invalid: "true" }],
[...fibTable, { ...fibTable[0], ".id": "*duplicate" }],
]) {
catalogs["/routing/table"] = rows
await assert.rejects(invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs), { code: "STATE_CONFLICT" })
assert.equal(writes, beforeEcmp)
}
catalogs["/routing/table"] = fibTable
const staleFib = await invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs) as { planId: string }
await approvePlan(staleFib.planId, actor, true)
catalogs["/routing/table"] = [{ ...fibTable[0], fib: "false" }]
assert.equal((await wait((await applyPlan(principal, staleFib.planId, randomUUID())).id)).state, "rejected")
assert.equal(writes, beforeEcmp)
catalogs["/routing/table"] = fibTable
console.log("ECMP FIB SQL/HTTP: inactive, unconfirmed and ambiguous tables refused; drift rejected before native write")
const equal = await invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs) as { planId: string }
const beforeEqual: number = writes, equalResult = await apply(equal.planId)
assert.equal(equalResult.state, "applied"); assert.equal(writes, beforeEqual + 3)
assert.equal(catalogs["/ip/settings"][0]["ipv4-multipath-hash-policy"], "l4")
const unequal = await invokeMcpTool(principal, "mm_operation_recovery_plan", { operationId: equalResult.operation.id }) as { planId: string }
assert.equal((await apply(unequal.planId)).state, "applied"); assert.equal(writes, beforeEqual + 6)
assert.deepEqual(catalogs["/ip/route"], routeBaseline); assert.deepEqual(catalogs["/ip/settings"], originalSettings)
const lateEcmp = await invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs) as { planId: string }
afterIntent = async () => { catalogs["/ip/route"][1]["immediate-gw"] = "" }
const lateEcmpResult = await apply(lateEcmp.planId)
assert.equal(afterIntent, undefined)
assert.equal(lateEcmpResult.state, "rejected")
assert.equal(lateEcmpResult.error_code, "STATE_CONFLICT")
assert.equal(writes, beforeEqual + 6)
catalogs["/ip/route"] = structuredClone(routeBaseline)
const sourceChanged = await invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs) as { planId: string }
let lateRouteReads = 0
const rotateDuringHealthRead = async () => {
if (++lateRouteReads === 1) {
// The prefix reads the configuration first; the next GET checks the live next-hop.
afterRouteRead = rotateDuringHealthRead
return
}
await assert.rejects(updateServer(serverId, { password: "concurrent-update-fixture" }), { statusCode: 409 })
await query("UPDATE servers SET password='health-source-aba-fixture' WHERE id=$1", [serverId])
await query("UPDATE servers SET password='fixture-only' WHERE id=$1", [serverId])
}
afterIntent = async () => { afterRouteRead = rotateDuringHealthRead }
const staleHealthSource = await apply(sourceChanged.planId)
assert.equal(lateRouteReads, 2)
assert.equal(staleHealthSource.state, "rejected")
assert.equal(staleHealthSource.error_code, "STATE_CONFLICT")
assert.equal(writes, beforeEqual + 6)
const beforeNextStep = await invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs) as { planId: string }
afterVerified = async () => { catalogs["/ip/route"][1]["immediate-gw"] = "" }
const partialEcmp = await apply(beforeNextStep.planId)
assert.equal(partialEcmp.state, "partial")
assert.equal(writes, beforeEqual + 7)
for (const [result, confirmedWrites] of [[lateEcmpResult, 0], [partialEcmp, 1]] as const) {
const stored = await invokeMcpTool(principal, "mm_operation_get", { operationId: result.operation.id }) as {
state: string; result: { failedStepDispatched: boolean; confirmedWrites: number; needsReview: boolean }
}
assert.equal(stored.state, result.state)
assert.equal(stored.result.failedStepDispatched, false)
assert.equal(stored.result.confirmedWrites, confirmedWrites)
assert.equal(stored.result.needsReview, confirmedWrites > 0)
const { rows: [stop] } = await pool.query("SELECT payload FROM mcp_audit WHERE action='operation.stop' AND payload->>'operationId'=$1", [result.operation.id])
assert.equal(stop.payload.failedStepDispatched, false)
}
catalogs["/ip/route"] = structuredClone(routeBaseline)
const collapsed = await invokeMcpTool(principal, "mm_ecmp_plan", { ...ecmpArgs, hashPolicy: undefined }) as { planId: string }
afterVerified = async () => {
afterVerified = async () => { catalogs["/ip/route"][1]["immediate-gw"] = routeBaseline[0]["immediate-gw"] }
}
const collapsedResult = await apply(collapsed.planId)
assert.equal(collapsedResult.state, "partial")
assert.equal(collapsedResult.verification, "failed")
assert.equal(writes, beforeEqual + 9)
assert.equal(afterVerified, undefined)
catalogs["/ip/route"] = structuredClone(routeBaseline)
console.log("ECMP SQL/HTTP: shared adjacency refuses preparation and post-write collapse fails verification")
const v6Baseline = routeBaseline.map((row, index) => ({
...row, "dst-address": "2001:db8::/64", gateway: `2001:db8:1::${index + 1}`,
"immediate-gw": `fe80::${index + 1}%fixture`,
}))
catalogs["/ipv6/route"] = structuredClone(v6Baseline)
const v6Settings = [{ "multipath-hash-policy": "l3" }]
catalogs["/ipv6/settings"] = structuredClone(v6Settings)
const v6Mangle = "/ipv6/firewall/mangle"
catalogs[v6Mangle] = [{ ".id": "*v6mark", action: "mark-routing", disabled: "false", "new-routing-mark": "NEVER_NATIVE_POLICY_MARK", bytes: "0", packets: "0" }]
const v6Args = { ...ecmpArgs, family: "ipv6" }
readPaths.clear()
for (const [path, field] of [[v6Mangle, "new-routing-mark"], [rulePath, "min-prefix"]]) {
const stale = await invokeMcpTool(principal, "mm_ecmp_plan", v6Args) as { planId: string; changes: { snapshots: Record<string, unknown>[] } }
const policy = stale.changes.snapshots.find(snapshot => snapshot.resource === "routing_policy:ipv6_mangle")
assert.deepEqual(policy, { serverId, resource: "routing_policy:ipv6_mangle", rowCount: 1 })
assert.ok(!JSON.stringify(stale).includes("NEVER_NATIVE_POLICY_MARK"))
await approvePlan(stale.planId, actor, true)
const original = catalogs[path], before = writes
catalogs[path] = [{ ...original[0], [field]: "17" }, ...original.slice(1)]
assert.equal((await wait((await applyPlan(principal, stale.planId, randomUUID())).id)).state, "rejected")
assert.equal(writes, before)
catalogs[path] = original
}
const v6Plan = await invokeMcpTool(principal, "mm_ecmp_plan", v6Args) as { planId: string }
catalogs[v6Mangle][0].bytes = "1500"
catalogs[v6Mangle][0].packets = "1"
const v6Result = await apply(v6Plan.planId)
assert.equal(v6Result.state, "applied")
assert.ok(catalogs["/ipv6/route"].every(row => row.active === "true" && row.distance === "10"))
assert.equal(catalogs["/ipv6/settings"][0]["multipath-hash-policy"], "l4")
const inverseArgs = { operationId: v6Result.operation.id }
catalogs[v6Mangle][0]["new-routing-mark"] = "external-mark"
await assert.rejects(invokeMcpTool(principal, "mm_operation_recovery_plan", inverseArgs), { code: "STATE_CONFLICT" })
catalogs[v6Mangle][0]["new-routing-mark"] = "NEVER_NATIVE_POLICY_MARK"
const v6Inverse = await invokeMcpTool(principal, "mm_operation_recovery_plan", inverseArgs) as { planId: string }
assert.ok(!JSON.stringify(v6Inverse).includes("NEVER_NATIVE_POLICY_MARK"))
assert.equal((await apply(v6Inverse.planId)).state, "applied")
assert.deepEqual(catalogs["/ipv6/route"], v6Baseline)
assert.deepEqual(catalogs["/ipv6/settings"], v6Settings)
assert.ok(![...readPaths].some(path => path === "/ip/settings" || path.startsWith("/ip/firewall/")))
console.log("ECMP IPv6 SQL/HTTP: family-specific reads, policy drift refusal, route/hash apply and inverse")
const rpfArgs = { serverId, mode: "strict", acknowledgeAsymmetry: true }
const manglePath = "/ip/firewall/mangle"
const beforeRpf = writes, beforeIpv6 = polls
await assert.rejects(invokeMcpTool(principal, "mm_rpf_plan", { ...rpfArgs, family: "ipv6" }), { code: "UNSUPPORTED_CAPABILITY" })
assert.equal(polls, beforeIpv6)
catalogs[rulePath][0].disabled = "false"
await assert.rejects(invokeMcpTool(principal, "mm_rpf_plan", rpfArgs), { code: "STATE_CONFLICT" })
const strictSettings = { serverId, fields: { "rp-filter": "strict", "ipv4-multipath-hash-policy": "l4" } }
await assert.rejects(invokeMcpTool(principal, "mm_ip_settings_update_plan", strictSettings), { code: "STATE_CONFLICT" })
catalogs[rulePath][0].disabled = "true"
catalogs[manglePath] = [{ ".id": "*mark", chain: "prerouting", action: "mark-routing", disabled: "false", "new-routing-mark": "fixture-mark" }]
await assert.rejects(invokeMcpTool(principal, "mm_rpf_plan", rpfArgs), { code: "STATE_CONFLICT" })
const loose = await invokeMcpTool(principal, "mm_rpf_plan", { ...rpfArgs, mode: "loose" }) as { changes: { warnings: string[] } }
assert.ok(loose.changes.warnings.some(warning => warning.includes("policy routing")))
catalogs[manglePath][0].disabled = "true"
const strict = await invokeMcpTool(principal, "mm_rpf_plan", rpfArgs) as { planId: string }
const genericStrict = await invokeMcpTool(principal, "mm_ip_settings_update_plan", strictSettings) as { planId: string }
await approvePlan(strict.planId, actor, true)
await approvePlan(genericStrict.planId, actor, true)
catalogs[manglePath][0]["new-routing-mark"] = "changed-after-approval"
const staleRpf = await applyPlan(principal, strict.planId, randomUUID())
assert.equal((await wait(staleRpf.id)).state, "rejected")
assert.equal((await wait((await applyPlan(principal, genericStrict.planId, randomUUID())).id)).state, "rejected")
assert.equal(writes, beforeRpf)
catalogs[manglePath] = []
catalogs["/ip/settings"][0]["rp-filter"] = "loose"
const disableRpf = await invokeMcpTool(principal, "mm_rpf_plan", { ...rpfArgs, mode: "disabled" }) as { planId: string }
assert.equal((await apply(disableRpf.planId)).state, "applied")
assert.deepEqual(catalogs["/ip/settings"], originalSettings)
catalogs["/ip/settings"][0]["rp-filter"] = "strict"
const leaveStrict = await invokeMcpTool(principal, "mm_rpf_plan", { ...rpfArgs, mode: "no" }) as { planId: string }
const leftStrict = await apply(leaveStrict.planId)
assert.equal(leftStrict.state, "applied")
const inverseRpfArgs = { operationId: leftStrict.operation.id }
const beforeInverseRpf = writes
catalogs[manglePath] = [{ ".id": "*late-mark", action: "mark-routing", disabled: "false" }]
await assert.rejects(invokeMcpTool(principal, "mm_operation_recovery_plan", inverseRpfArgs), { code: "STATE_CONFLICT" })
catalogs[manglePath] = []
const legacyRpf = await invokeMcpTool(principal, "mm_operation_recovery_plan", inverseRpfArgs) as { planId: string }
const legacyRpfEvidence = await evidence(legacyRpf.planId)
legacyRpfEvidence.snapshots = legacyRpfEvidence.snapshots.filter(snapshot => !snapshot.resource.startsWith("routing_policy:"))
await replace(legacyRpf.planId, legacyRpfEvidence)
await approvePlan(legacyRpf.planId, actor, true)
await assert.rejects(applyPlan(principal, legacyRpf.planId, randomUUID()), { code: "PLAN_EXPIRED" })
const staleInverseRpf = await invokeMcpTool(principal, "mm_operation_recovery_plan", inverseRpfArgs) as { planId: string }
await approvePlan(staleInverseRpf.planId, actor, true)
catalogs[manglePath] = [{ ".id": "*late-mark", action: "mark-routing", disabled: "false" }]
assert.equal((await wait((await applyPlan(principal, staleInverseRpf.planId, randomUUID())).id)).state, "rejected")
assert.equal(writes, beforeInverseRpf)
catalogs[manglePath] = []
const safeInverseRpf = await invokeMcpTool(principal, "mm_operation_recovery_plan", inverseRpfArgs) as { planId: string }
assert.equal((await apply(safeInverseRpf.planId)).state, "applied")
assert.equal(catalogs["/ip/settings"][0]["rp-filter"], "strict")
catalogs["/ip/settings"] = structuredClone(originalSettings)
console.log("RPF inverse SQL/HTTP: active policy, legacy plans and post-approval drift refused; exact strict restoration under verified policy")
console.log("RPF SQL/HTTP: explicit IPv6 refusal, strict policy admission and drift, loose warning, disabled alias")
for (const [tool, args, path] of [
["mm_rpf_plan", { serverId, mode: "loose", acknowledgeAsymmetry: true }, "/ip/settings"],
["mm_ip_settings_update_plan", { serverId, fields: { "rp-filter": "loose" } }, "/ip/settings"],
@@ -229,6 +463,43 @@ export async function acceptNativeConservation(actor: AuthUser) {
const booleanResult = await apply(booleanPlan.planId); assert.equal(booleanResult.state, "applied")
const booleanInverse = await invokeMcpTool(principal, "mm_operation_recovery_plan", { operationId: booleanResult.operation.id }) as { planId: string }
assert.equal((await apply(booleanInverse.planId)).state, "applied"); assert.deepEqual(catalogs[filterPath], booleanBaseline)
catalogs["/ip/route"] = routeBaseline.map((row, index) => ({ ...row, gateway: `gre${index}`, "immediate-gw": `gre${index}` }))
const stableHopPlan = await invokeMcpTool(principal, "mm_ecmp_plan", { ...ecmpArgs, hashPolicy: undefined }) as { planId: string }
const beforeStableHops = writes
const stableHops = await apply(stableHopPlan.planId)
assert.equal(stableHops.state, "applied")
assert.equal(writes, beforeStableHops + 2)
assert.ok(catalogs["/ip/route"].every(row => row.distance === "10" && row.active === "true"))
console.log("ECMP interface-only next-hop: unchanged interface IDs verified before both writes and after apply")
const namedHopInverseArgs = { operationId: stableHops.operation.id }
const plansBeforeReplacement = (await query("SELECT count(*)::int AS count FROM mcp_plans WHERE connection_id=$1", [connectionId])).rows[0].count
catalogs["/interface"][0][".id"] = "*replaced-before-preparation"
await assert.rejects(invokeMcpTool(principal, "mm_operation_recovery_plan", namedHopInverseArgs), { code: "STATE_CONFLICT" })
assert.equal((await query("SELECT count(*)::int AS count FROM mcp_plans WHERE connection_id=$1", [connectionId])).rows[0].count, plansBeforeReplacement)
catalogs["/interface"][0][".id"] = "*gre0"
const oldHopInverse = await invokeMcpTool(principal, "mm_operation_recovery_plan", namedHopInverseArgs) as { planId: string }
const oldHopEvidence = await evidence(oldHopInverse.planId)
delete oldHopEvidence.healthChecks
await replace(oldHopInverse.planId, oldHopEvidence)
const beforeInverseHops = writes
const oldHopResult = await apply(oldHopInverse.planId)
assert.equal(oldHopResult.state, "rejected")
assert.equal(oldHopResult.error_code, "PLAN_EXPIRED")
assert.equal(writes, beforeInverseHops)
const staleHopInverse = await invokeMcpTool(principal, "mm_operation_recovery_plan", namedHopInverseArgs) as { planId: string; changes: unknown }
assert.ok(!JSON.stringify(staleHopInverse.changes).includes("nextHopInterface"))
afterIntent = async () => { catalogs["/interface"][0][".id"] = "*inverse-replacement" }
const refusedInverse = await apply(staleHopInverse.planId)
assert.equal(afterIntent, undefined)
assert.equal(refusedInverse.state, "rejected")
assert.equal(refusedInverse.error_code, "STATE_CONFLICT")
assert.equal(writes, beforeInverseHops)
catalogs["/interface"][0][".id"] = "*gre0"
const safeHopInverse = await invokeMcpTool(principal, "mm_operation_recovery_plan", namedHopInverseArgs) as { planId: string }
assert.equal((await apply(safeHopInverse.planId)).state, "applied")
assert.equal(writes, beforeInverseHops + 2)
assert.deepEqual(catalogs["/ip/route"].map(row => row.distance), ["10", "20"])
console.log("ECMP inverse: replaced interface refused before PATCH; intact interface restores unequal costs")
console.log("Native multi-catalog inverse SQL/HTTP: two route PATCHes plus singleton hash policy, exact reverse prefix, RPF/raw singleton, numeric zero, false and empty-comment originals preserved")
console.log("Native conservation SQL/HTTP: C/U/D and safe inverses; assigned-ID, membership, field and order failures never VERIFIED/replayed; honest two-step prefix stops before second intent; late source/dependency/protection/policy fence zero writes; stale operation revision refused. No CHR/opaque-field/forwarding claim.")
} finally {
@@ -11,7 +11,7 @@ const creates = rosResources.filter(resource => !resource.readOnly && resource.c
assert.equal(creates.length, 30)
for (const resource of creates) {
const action = findAction(`mm_${resource.id}_create_plan`)
assert.equal(action.operation.version, ["ospf_template", "ipsec_peer"].includes(resource.id) ? 5 : ["wireguard", "vrf"].includes(resource.id) ? 4 : 3)
assert.equal(action.operation.version, ["ospf_template", "ipsec_peer", "vrf"].includes(resource.id) ? 5 : ["wireguard", "bridge", "bridge_port", "bridge_vlan"].includes(resource.id) ? 4 : 3)
assert.equal(action.schema.safeParse({ serverId: 1, fields: {} }).success, true)
for (const id of ["*OLD", undefined, null, ""]) {
const rejected = action.schema.safeParse({ serverId: 1, fields: {}, id })
@@ -32,13 +32,13 @@ assert.equal(rebuildReconcile.operation.effect, "read")
assert.equal(rebuildReconcile.operation.scope, "global")
assert.deepEqual(rebuildReconcile.operation.requiredPermissions, ["mm:traffic:write"])
assert.throws(() => rebuildReconcile.schema.parse({ jobId: "caller" }))
assert.equal(action.operation.version, 9)
assert.equal(findMcpOperation(action.operation.id)?.version, 9)
assert.equal(action.operation.version, 10)
assert.equal(findMcpOperation(action.operation.id)?.version, 10)
assert.throws(() => action.schema.parse({ serverId: 1, expectedServerProof: "forged-source" }))
assert.throws(() => action.schema.parse({ serverId: 1, version: 1 }))
assert.equal(action.schema.parse({ serverId: 1 }).family, "ipv4")
assert.equal(findAction("mm_routing_measurement_plan").operation.version, 2)
assert.equal(findAction("mm_operation_recovery_plan").operation.version, 7)
assert.equal(findAction("mm_operation_recovery_plan").operation.version, 8)
assert.equal(findAction("mm_ospf_template_create_plan").operation.version, 5)
assert.equal(findAction("mm_ospf_template_update_plan").operation.version, 4)
assert.equal(findAction("mm_ospf_template_delete_plan").operation.version, 3)
@@ -49,17 +49,17 @@ assert.equal(findAction("mm_wireguard_update_plan").operation.version, 3)
assert.equal(findAction("mm_wireguard_interface_edit_plan").operation.version, 3)
assert.equal(findAction("mm_wireguard_interface_create_plan").operation.version, 3)
assert.equal(findAction("mm_wireguard_create_plan").operation.version, 4)
assert.equal(findAction("mm_interfaces_update_plan").operation.version, 4)
assert.equal(findAction("mm_interfaces_update_plan").operation.version, 5)
assert.equal(findAction("mm_ipsec_peer_delete_plan").operation.version, 3)
assert.equal(findAction("mm_ipsec_peer_update_plan").operation.version, 3)
assert.equal(findAction("mm_certificate_delete_plan").operation.version, 3)
for (const [tool, version] of [["mm_route_optimizer_plan", 7], ["mm_route_profile_save_plan", 4], ["mm_route_path_verify", 2], ["mm_operation_recovery_plan", 7], ["mm_ecmp_plan", 2], ["mm_rpf_plan", 2], ["mm_vrf_plan", 3], ["mm_firewall_rule_update_plan", 2]] as const) {
for (const [tool, version] of [["mm_route_optimizer_plan", 7], ["mm_route_profile_save_plan", 5], ["mm_route_path_verify", 2], ["mm_operation_recovery_plan", 8], ["mm_ecmp_plan", 7], ["mm_rpf_plan", 4], ["mm_ip_settings_update_plan", 3], ["mm_vrf_plan", 4], ["mm_firewall_rule_update_plan", 2]] as const) {
const routed = findAction(tool)
assert.equal(routed.operation.version, version)
assert.equal(findMcpOperation(routed.operation.id)?.version, version)
}
assert.throws(() => findAction("mm_routing_measurement_plan").schema.parse({ measurement: { srcServerId: 1, dstServerId: 2, srcInterface: "gre", dstInterface: "gre", family: "ipv4", expectedEndpoints: [] } }))
console.log("MCP operation revisions: OSPF v9 and template order revisions agree; clients cannot inject revision or private source proof")
console.log("MCP operation revisions: OSPF v10 and template order revisions agree; clients cannot inject revision or private source proof")
assert.equal(findAction("mm_ipsec_peer_create_plan").operation.version, 5)
assert.equal(findAction("mm_certificate_update_plan").operation.version, 4)
@@ -9,9 +9,10 @@ await prepareOwnedBandwidthState()
const {pool,closePool}=await import("../../db/index.js")
const {applySqlMigrations}=await import("../../db/migrate.js")
const {mcpConnectionCreateSchema}=await import("@mmapp/contracts/mcp")
const {createMcpConnection,authenticateMcpToken}=await import("./store.js")
const {createMcpConnection,authenticateMcpToken,getMcpSettings}=await import("./store.js")
const {invokeMcpTool,approvePlan,applyPlan}=await import("./runtime.js")
const {OrdinaryRetirementCommitUnknown,retireOrdinaryMcpOperations}=await import("./ordinary-operation-retirement.js")
const {PARTITION_WRITER_ADVISORY_KEY}=await import("../../services/partition-write-transaction.js")
const {tickMcpWorkers}=await import("./workers.js")
const {pauseSchedulerJobsAndWait,resumeSchedulerJobs}=await import("../../services/scheduler-running.js")
const {Client}=await import("pg")
@@ -20,9 +21,58 @@ const {touchGeoipRunMeta}=await import("../../services/geoip-settings.js")
const {touchBackupScheduleRunMeta}=await import("../../services/backup-service.js")
const errorIs=(code:string,status:number)=>(error:unknown)=>error instanceof McpError&&error.code===code&&error.statusCode===status
async function assertWorkerUsesCommittedRetention() {
const actorId=randomUUID()
const original=await getMcpSettings()
await pool.query("UPDATE mcp_settings SET requests_retention_days=1,audit_retention_days=30 WHERE id=1")
const settingsWriter=await pool.connect()
let tick:Promise<void>|undefined,settled=false,transactionOpen=false
try{
await pool.query(`INSERT INTO mcp_requests(id,actor_id,tool,status,created_at) VALUES
($1,$5,'protected','completed',now()-interval '2 days'),
($2,$5,'active','running',now()-interval '33 days'),
($3,$5,'recent','completed',now()-interval '1 hour'),
($4,$5,'expired','completed',now()-interval '33 days')`,
[randomUUID(),randomUUID(),randomUUID(),randomUUID(),actorId])
await pool.query(`INSERT INTO mcp_audit(actor_id,action,created_at) VALUES
($1,'protected',now()-interval '31 days'),($1,'recent',now()-interval '1 hour'),
($1,'expired',now()-interval '183 days')`,[actorId])
await settingsWriter.query("BEGIN")
transactionOpen=true
const {pid}=(await settingsWriter.query("SELECT pg_backend_pid() AS pid")).rows[0]
await settingsWriter.query("UPDATE mcp_settings SET requests_retention_days=30,audit_retention_days=180 WHERE id=1")
tick=tickMcpWorkers().finally(()=>{settled=true})
void tick.catch(()=>{})
let waiting=false
for(const deadline=performance.now()+3000;!settled&&!waiting&&performance.now()<deadline;){
waiting=(await pool.query(`SELECT EXISTS(SELECT 1 FROM pg_stat_activity
WHERE $1=ANY(pg_blocking_pids(pid)) AND query LIKE '%FROM mcp_settings WHERE id=1 FOR SHARE%') AS waiting`,[pid])).rows[0].waiting
if(!waiting)await delay(10)
}
assert(waiting,"retention worker must wait for the policy update before choosing candidates")
assert.deepEqual(await getMcpSettings(),{...original,requestsRetentionDays:1,auditRetentionDays:30})
await settingsWriter.query("COMMIT")
transactionOpen=false
await tick
assert.deepEqual(await getMcpSettings(),{...original,requestsRetentionDays:30,auditRetentionDays:180})
assert.deepEqual((await pool.query("SELECT tool,status FROM mcp_requests WHERE actor_id=$1 ORDER BY tool",[actorId])).rows,
[{tool:"active",status:"running"},{tool:"protected",status:"completed"},{tool:"recent",status:"completed"}])
assert.deepEqual((await pool.query("SELECT action FROM mcp_audit WHERE actor_id=$1 ORDER BY action",[actorId])).rows,
[{action:"protected"},{action:"recent"}])
}finally{
try{if(transactionOpen)await settingsWriter.query("ROLLBACK")}finally{settingsWriter.release()}
await tick?.catch(()=>{})
await pool.query("DELETE FROM mcp_requests WHERE actor_id=$1",[actorId])
await pool.query("DELETE FROM mcp_audit WHERE actor_id=$1",[actorId])
await pool.query("UPDATE mcp_settings SET requests_retention_days=$1,audit_retention_days=$2 WHERE id=1",[original.requestsRetentionDays,original.auditRetentionDays])
}
console.log("Owned MCP retention: worker waits for committed policy, preserves protected and running rows, and deletes expired rows")
}
try {
await applySqlMigrations(pool)
await pauseSchedulerJobsAndWait()
await assertWorkerUsesCommittedRetention()
await pool.query("UPDATE public.mcp_settings SET enabled=true WHERE id=1")
const serverId=Number((await pool.query("INSERT INTO servers(name,host,enabled) VALUES('owned logs retention','127.0.0.1',false) RETURNING id")).rows[0].id)
async function access(){
@@ -31,12 +81,18 @@ try {
issuedAt:Date.now()-1000,expiresAt:Date.now()+300000}
const issued=await createMcpConnection(mcpConnectionCreateSchema.parse({name:actor.name,mode:"approval",
permissions:actor.permissions,expiresAt:new Date(actor.expiresAt).toISOString(),
scope:{serverIds:[serverId],operationIds:["logs.settings.update","traffic.settings.update","uptime.settings.update",
scope:{serverIds:[serverId],operationIds:["logs.settings.read","logs.settings.update","traffic.settings.update","uptime.settings.update",
"servers.api-ping.settings.update","internet-path.settings.update","geoip.settings.update","backups.schedule.update","operations.read","changes.apply"],allowGlobal:true},
limits:{callsPerMinute:100,mutationsPerHour:20}}),actor)
return {actor,principal:await authenticateMcpToken(issued.token)}
}
const {actor,principal}=await access(),foreign=(await access()).principal
const initialLogs=await invokeMcpTool(principal,"mm_logs_settings_get",{})
assert.deepEqual(initialLogs,{enabled:false,intervalSec:10,retentionDays:14})
const shorterLogs=await invokeMcpTool(principal,"mm_logs_settings_update_plan",{settings:{retentionDays:7}}) as {state:string;changes:{warnings:string[]}}
assert.equal(shorterLogs.state,"awaiting-approval")
assert.match(shorterLogs.changes.warnings.join(" "),/с 14 до 7 суток.*полученные более 7 суток назад/)
assert.deepEqual(await invokeMcpTool(principal,"mm_logs_settings_get",{}),initialLogs)
async function operation(retentionDays:number,ageMs:number,tool="mm_logs_settings_update_plan",
settings:Record<string,unknown>={retentionDays}){
const plan=await invokeMcpTool(principal,tool,{settings}) as {planId:string;state:string}
@@ -79,6 +135,38 @@ try {
LEFT JOIN mcp_operation_steps s ON s.operation_id=i.id LEFT JOIN mcp_operation_checkpoints k ON k.operation_id=i.id
LEFT JOIN mcp_operation_replay_tombstones t ON t.operation_id=i.id
LEFT JOIN mcp_ordinary_retirement_claims c ON c.operation_id=i.id ORDER BY i.id`,[ids])).rows
const beforeFence=await projection()
const exclusive=await pool.connect()
let blockedAttempt:ReturnType<typeof retireOrdinaryMcpOperations>|undefined
try{
await exclusive.query("SELECT pg_advisory_lock($1::bigint)",[PARTITION_WRITER_ADVISORY_KEY])
await exclusive.query("BEGIN")
blockedAttempt=retireOrdinaryMcpOperations()
void blockedAttempt.catch(()=>{})
let waiting=false
for(const deadline=Date.now()+3000;!waiting&&Date.now()<deadline;){
waiting=(await exclusive.query(`SELECT EXISTS(SELECT 1 FROM pg_catalog.pg_locks
WHERE locktype='advisory' AND database=(SELECT oid FROM pg_catalog.pg_database WHERE datname=current_database())
AND classid=0 AND objid=$1::oid AND objsubid=1 AND mode='ShareLock' AND NOT granted) AS waiting`,[PARTITION_WRITER_ADVISORY_KEY])).rows[0].waiting
if(!waiting)await delay(10)
}
assert.equal(waiting,true,"the retiring checkout must wait at the common fence")
assert.equal((await exclusive.query("SELECT id FROM public.mcp_connections WHERE id=$1 FOR UPDATE NOWAIT",[principal.connectionId])).rows.length,1)
for(const candidate of [spent,due]){
const {rows:[row]}=await exclusive.query("SELECT pg_try_advisory_xact_lock(hashtextextended($1,0)) AS locked",["mcp-operation:"+candidate.id])
assert.equal(row.locked,true,"the retiring checkout must not hold a secondary operation key")
}
const blocked=await blockedAttempt
assert(blocked.scanned>0);assert.equal(blocked.retired,0);assert.equal(blocked.automaticReplay,false)
assert.deepEqual(await projection(),beforeFence)
}finally{
try{await blockedAttempt}finally{
try{
await exclusive.query("ROLLBACK")
assert.equal((await exclusive.query("SELECT pg_advisory_unlock($1::bigint) AS unlocked",[PARTITION_WRITER_ADVISORY_KEY])).rows[0].unlocked,true)
}finally{exclusive.release(true)}
}
}
const originalQuery=Client.prototype.query
let commits=0
try{
@@ -5,6 +5,7 @@ import { backupScheduleSettingsDtoSchema, putBackupScheduleSettingsSchema } from
import { z } from "zod"
import { connectProbeStorage } from "../../services/server-probe-storage-connection.js"
import type { ProbeStorageSql } from "../../services/server-probe-journal-maintenance.js"
import { admitPartitionWriter } from "../../services/partition-write-transaction.js"
import { sharedDatabaseGate } from "../../services/shared-database-gate.js"
import { operationIdempotencyDigest } from "./operation-replay-tombstones.js"
import { decryptSecretExisting, fingerprint, privateFingerprinterExisting, redact } from "./security.js"
@@ -110,11 +111,13 @@ const SOURCE = `SELECT to_jsonb(o) AS operation,to_jsonb(p) AS plan,to_jsonb(s)
AND octet_length(to_jsonb(o)::text)+octet_length(to_jsonb(p)::text)+octet_length(to_jsonb(s)::text)+octet_length(to_jsonb(k)::text)<=1048576
FOR UPDATE OF o,p,s,k NOWAIT`
async function transaction<T>(deadlineAt:number, work:(sql:ProbeStorageSql)=>Promise<T>):Promise<T> {
async function transaction<T>(deadlineAt:number, mode:"scan"|"write", work:(sql:ProbeStorageSql)=>Promise<T>):Promise<T> {
const sql = await connectProbeStorage({deadlineAt})
let committing=false,committed=false
try {
await sql.query("BEGIN")
// Preserve the one-second lock budget and admit before any target row or operation lock.
if(mode==="write")await admitPartitionWriter(sql,{deadlineAt:Math.min(deadlineAt,Date.now()+1000)})
await sql.query("SELECT set_config('TimeZone','UTC',true),set_config('statement_timeout','5000ms',true),set_config('lock_timeout','1000ms',true)")
const result=await work(sql)
committing=true
@@ -173,13 +176,13 @@ async function prepare(sql:ProbeStorageSql, operationId:string, connectionId:str
async function retire(operationId:string,connectionId:string,deadlineAt:number) {
const token=randomUUID()
await transaction(deadlineAt,async sql=>{
await transaction(deadlineAt,"write",async sql=>{
const source=await prepare(sql,operationId,connectionId)
const claim=await sql.query(`INSERT INTO public.mcp_ordinary_retirement_claims(operation_id,plan_id,connection_id,source_proof,claim_token)
VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING RETURNING operation_id`,[operationId,source.planId,connectionId,source.proof,token])
requireThat(claim.rows.length===1&&claim.rows[0].operation_id===operationId)
})
await transaction(deadlineAt,async sql=>{
await transaction(deadlineAt,"write",async sql=>{
const claim=(await sql.query(`SELECT source_proof,plan_id FROM public.mcp_ordinary_retirement_claims
WHERE operation_id=$1 AND connection_id=$2 AND claim_token=$3 AND claim_xid<>pg_current_xact_id()`,[operationId,connectionId,token])).rows
requireThat(claim.length===1)
@@ -203,7 +206,7 @@ let cursor:{finishedAt:string;operationId:string}|undefined
export function retireOrdinaryMcpOperations() {
return sharedDatabaseGate.request(async()=>{
const deadlineAt=Date.now()+15000
const rows=await transaction(deadlineAt,async sql=>(await sql.query(CANDIDATES,[cursor?.finishedAt??null,cursor?.operationId??null])).rows)
const rows=await transaction(deadlineAt,"scan",async sql=>(await sql.query(CANDIDATES,[cursor?.finishedAt??null,cursor?.operationId??null])).rows)
requireThat(rows.length<=8)
let scanned=0,retired=0
for(const row of rows){
@@ -2,7 +2,7 @@ import assert from "node:assert/strict"
import { createOspfOwnerSourceGuard, verifyOspfOwnerGraph, verifyOspfOwnerGraphAtPhase } from "./ospf-owner-graph.js"
import { fingerprint, McpError, redact } from "./security.js"
import { createRosPrefix } from "./ros-prefix.js"
import { ROS_SNAPSHOT_VERSION, type RosSnapshot } from "./resources.js"
import { rosSnapshotVersion, type RosSnapshot } from "./resources.js"
const ids = [1, 2, 3]
const graph = { family: "ipv4" as const, sourceSecretProofs: ids.map(serverId => ({ serverId, proof: fingerprint({ serverId }) })), instances: ids.map(serverId => ({ serverId, fingerprint: fingerprint({ serverId, native: "instances" }) })) }
@@ -54,7 +54,7 @@ const catalogAuthorize = async () => {
await sourceGuard()
}
const catalogRows = [{ ".id": "*A", name: "keep", disabled: "false" }]
const catalog: RosSnapshot = { serverId: 1, resource: "bridge", rows: catalogRows, fingerprint: fingerprint(catalogRows), projectionVersion: ROS_SNAPSHOT_VERSION, sourceProof: "a".repeat(64) }
const catalog: RosSnapshot = { serverId: 1, resource: "bridge", rows: catalogRows, fingerprint: fingerprint(catalogRows), projectionVersion: rosSnapshotVersion("bridge"), sourceProof: "a".repeat(64) }
reads = []
const catalogPrefix = createRosPrefix([catalog], async (_id, _resource, beforeRead = async () => {}) => {
// Match readRosSnapshot's normal three grant/source fences without polling a
@@ -3,6 +3,7 @@ import { z } from "zod"
export const pathProofPurposeSchema = z.enum(["community", "control-path"])
export type PathProofPurpose = z.infer<typeof pathProofPurposeSchema>
export const PATH_PROOF_VERSION = 2
export const COMMUNITY_PATH_MAX_AGE_MS = 90000
export const pathProofTag = (purpose: PathProofPurpose) => "path-check:" + purpose + ":v" + PATH_PROOF_VERSION + ":"
export function isCommunityPathEvidence(value: Record<string, unknown>) {
return value.proofPurpose === "community" && value.proofVersion === PATH_PROOF_VERSION
+3 -3
View File
@@ -8,7 +8,7 @@ import { fingerprint, McpError } from "./security.js"
import { readRouteCommunityContext, routeContextAccess, type RouteContextOptions } from "./routing-community-context.js"
import type { add } from "./actions.js"
import { readControlPathContext } from "./control-path-context.js"
import { isCommunityPathEvidence, PATH_PROOF_VERSION, pathProofPurposeSchema, type PathProofPurpose } from "./path-proof-purpose.js"
import { COMMUNITY_PATH_MAX_AGE_MS, isCommunityPathEvidence, PATH_PROOF_VERSION, pathProofPurposeSchema, type PathProofPurpose } from "./path-proof-purpose.js"
export function inspectControlPing(rows: Parameters<typeof parsePing>[0]) {
const parsed = parsePing(rows)
@@ -107,10 +107,10 @@ export function registerPathActions(register: typeof add) {
export async function requireRecentPath(profile: RouteAiProfile, candidateId: string, connectionId: string, options: RouteContextOptions = {}) {
// Compare the database timestamp with the database clock. The signed
// observedAt below is independently checked against the application clock.
const { rows: [row] } = await pool.query("SELECT id,status,created_at,evidence,created_at BETWEEN clock_timestamp()-interval '90 seconds' AND clock_timestamp() AS database_fresh FROM mcp_path_checks WHERE connection_id=$1 AND profile_hash=$2 AND candidate_id=$3 AND evidence->>'proofPurpose'='community' AND evidence->>'proofVersion'='2' ORDER BY created_at DESC,id DESC LIMIT 1", [connectionId, fingerprint(profile), candidateId])
const { rows: [row] } = await pool.query("SELECT id,status,created_at,evidence,created_at BETWEEN clock_timestamp()-$4::int * interval '1 millisecond' AND clock_timestamp() AS database_fresh FROM mcp_path_checks WHERE connection_id=$1 AND profile_hash=$2 AND candidate_id=$3 AND evidence->>'proofPurpose'='community' AND evidence->>'proofVersion'='2' ORDER BY created_at DESC,id DESC LIMIT 1", [connectionId, fingerprint(profile), candidateId, COMMUNITY_PATH_MAX_AGE_MS])
if (!row || row.status !== "passed" || row.database_fresh !== true) throw new McpError("MEASUREMENT_UNKNOWN", "Нет свежего подтверждения контрольного пути", 409)
const { proof, ...evidence } = row.evidence ?? {}
if (!isCommunityPathEvidence(evidence) || evidence.id !== row.id || evidence.connectionId !== connectionId || evidence.profileHash !== fingerprint(profile) || evidence.candidateId !== candidateId || evidence.status !== "passed" || !Array.isArray(evidence.sourceProofs) || !evidence.sourceProofs.length || !Number.isFinite(Date.parse(evidence.observedAt)) || Date.now() - Date.parse(evidence.observedAt) > 90000 || Date.parse(evidence.observedAt) > Date.now()) throw new McpError("MEASUREMENT_UNKNOWN", "Подтверждение пути не связано с Community purpose, источником и контекстом", 409)
if (!isCommunityPathEvidence(evidence) || evidence.id !== row.id || evidence.connectionId !== connectionId || evidence.profileHash !== fingerprint(profile) || evidence.candidateId !== candidateId || evidence.status !== "passed" || !Array.isArray(evidence.sourceProofs) || !evidence.sourceProofs.length || !Number.isFinite(Date.parse(evidence.observedAt)) || Date.now() - Date.parse(evidence.observedAt) > COMMUNITY_PATH_MAX_AGE_MS || Date.parse(evidence.observedAt) > Date.now()) throw new McpError("MEASUREMENT_UNKNOWN", "Подтверждение пути не связано с Community purpose, источником и контекстом", 409)
const current = await readRouteCommunityContext(profile, { ...options, selectedCandidateId: candidateId, expectedSources: evidence.sourceProofs })
if (!current.verifyEvidence(evidence, proof)) throw new McpError("MEASUREMENT_UNKNOWN", "Зависимости пути или сохранённое подтверждение изменились", 409)
return row.id as string
@@ -5,6 +5,14 @@ import { preparedPlanExpiry } from "./plan-expiry.js"
const iso = (milliseconds: number) => new Date(Date.now() + milliseconds).toISOString()
const poll = (validUntil?: string) => preparedPlanExpiry("servers.poll", "servers", { validUntil })
test("Community honors sealed validity but keeps the ordinary lifetime without a bound", () => {
const bound = iso(90000)
assert.equal(preparedPlanExpiry("routing.community.switch", "network", { validUntil: bound })?.toISOString(), bound)
assert.equal(preparedPlanExpiry("routing.community.switch", "network", { validUntil: iso(-1000) }), null)
const before = Date.now(), manual = preparedPlanExpiry("routing.community.switch", "network", {})!.getTime()
assert(manual >= before + 300000 && manual <= Date.now() + 300000)
})
test("Poll uses its existing sealed five-minute validity as the nominal window", () => {
const bound = iso(300000)
assert.equal(poll(bound)?.toISOString(), bound)
+5 -3
View File
@@ -3,11 +3,13 @@ import type { ChangeEvidence } from "./action-types.js"
/** Internal value calculation only; evidence comes from action.prepare, never
* caller JSON. Generic plans may outlive their preparation access credential.
* A missing result makes the runtime refuse Poll before storing a new plan. */
* A missing result refuses a time-bound plan before storage. */
export function preparedPlanExpiry(operationId: string, section: string, evidence: Pick<ChangeEvidence, "validUntil">): Date | null {
const now = Date.now(), ordinaryExpiry = now + (section === "network" ? 300000 : 900000)
if (operationId !== "servers.poll") return new Date(ordinaryExpiry)
const validity = z.string().datetime({ offset: true }).safeParse(evidence.validUntil)
if (operationId !== "servers.poll" && operationId !== "routing.community.switch") return new Date(ordinaryExpiry)
const validUntil = evidence.validUntil
if (operationId === "routing.community.switch" && validUntil === undefined) return new Date(ordinaryExpiry)
const validity = z.string().datetime({ offset: true }).safeParse(validUntil)
const bound = validity.success ? Date.parse(validity.data) : NaN
if (!Number.isFinite(bound) || bound <= now) return null
return new Date(Math.min(ordinaryExpiry, bound))
+47 -1
View File
@@ -21,7 +21,7 @@ test("failed/malformed/redirected and stalled protocol probes are bounded and ne
try {
for (const state of ["malformed", "redirect", "large", "announced", "stalled"]) {
mode = state
const started = performance.now(), result = await runMcpProtocolProbe(endpoint, "private-readiness-fixture", 300)
const started = performance.now(), result = await runMcpProtocolProbe(endpoint, "private-readiness-fixture", { timeoutMs: 300 })
assert.equal(result.status, "failed", state)
assert.ok(performance.now() - started < 3000, state)
assert.ok(!JSON.stringify(result).includes("private-readiness-fixture"))
@@ -29,3 +29,49 @@ test("failed/malformed/redirected and stalled protocol probes are bounded and ne
assert.equal(forwarded, 0); assert.ok(requests >= 5)
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) }
})
test("capabilities readiness refuses widened scope and checks the tool result", async () => {
const capability = { id: "capabilities.read", toolName: "mm_capabilities_get" }
const tool = { name: capability.toolName, inputSchema: { type: "object" } }
let mode = "extra-tool", calls = 0
const server = createServer(async (req, res) => {
if (req.method !== "POST") { res.writeHead(405).end(); return }
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(chunk)
const { id, method, params } = JSON.parse(Buffer.concat(chunks).toString())
if (id === undefined) { res.writeHead(202).end(); return }
let result: unknown
if (method === "initialize") result = { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1.0.0" } }
if (method === "tools/list") result = { tools: mode === "extra-tool" ? [tool, { ...tool, name: "mm_servers_list" }] : [tool] }
if (method === "tools/call") {
calls++
assert.equal(params.name, capability.toolName)
if (mode === "timeout") { res.writeHead(200, { "content-type": "application/json" }).write("{"); return }
const operations = mode === "extra-operation" ? [capability, { id: "servers.list" }] : [capability]
result = { content: [], isError: mode === "tool-error", structuredContent: { result: { schemaVersion: 1, operations } } }
}
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ jsonrpc: "2.0", id, result }))
})
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
const endpoint = new URL(`http://127.0.0.1:${(server.address() as { port: number }).port}/mcp`)
const probe = () => runMcpProtocolProbe(endpoint, "private-readiness-fixture", { timeoutMs: 400, readCapabilities: true })
try {
const widened = await probe()
assert.equal(widened.reason, "SCOPE_NOT_ENFORCED")
assert.equal(widened.capabilitiesRead, "skipped")
assert.equal(calls, 0)
for (const state of ["tool-error", "extra-operation", "timeout"]) {
mode = state
const result = await probe()
assert.equal(result.status, "failed", state)
assert.equal(result.capabilitiesRead, "failed", state)
assert.equal(result.reason, state === "timeout" ? "TIMEOUT" : "CAPABILITIES_READ_FAILED", state)
}
mode = "valid"
const valid = await probe()
assert.equal(valid.status, "passed")
assert.equal(valid.capabilitiesRead, "passed")
assert.equal(valid.clientCredentialsVerified, false)
assert.equal(calls, 4)
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) }
})
+28 -12
View File
@@ -9,15 +9,19 @@ export interface ProtocolProbe {
status: "passed" | "failed" | "skipped";
elapsedMs: number;
reason?: string;
credentialType: "temporary-no-operations";
credentialType: "temporary-no-operations" | "temporary-capabilities-read";
capabilitiesRead: "passed" | "failed" | "skipped";
clientCredentialsVerified: false;
}
/** A temporary access credential may negotiate metadata, but its empty grant
* permits no tools. The ordinary connection credentials are never recovered. */
export async function withMcpProbeCredential<T>(connectionId: string, actor: AuthUser, work: (token: string) => Promise<T>): Promise<T> {
const capabilitiesOperation = "capabilities.read"
const capabilitiesTool = "mm_capabilities_get"
/** The probe may read its own narrowed capabilities when the connection grants
* that operation. The ordinary connection credentials are never recovered. */
export async function withMcpProbeCredential<T>(connectionId: string, actor: AuthUser, work: (token: string, readCapabilities: boolean) => Promise<T>): Promise<T> {
const credentialId = randomUUID(), grantId = randomUUID(), token = randomToken("access")
await transaction(async client => {
const readCapabilities = await transaction(async client => {
await client.query("SELECT id FROM mcp_connections WHERE id=$1 FOR UPDATE", [connectionId])
const connection = await getConnection(connectionId, client)
assertConnectionVisible(connection, actor)
@@ -27,14 +31,16 @@ export async function withMcpProbeCredential<T>(connectionId: string, actor: Aut
const expired = (await client.query("DELETE FROM mcp_credentials WHERE connection_id=$1 AND client_id='mcp-readiness-probe' AND expires_at<=now() RETURNING id", [connectionId])).rows.map(row => row.id)
if (expired.length) await client.query("DELETE FROM mcp_grants WHERE connection_id=$1 AND payload->>'credentialId'=ANY($2::text[])", [connectionId, expired])
const expiresAt = new Date(Math.min(Date.now() + 20000, Date.parse(connection.policy.expiresAt), connection.owner_valid_until.getTime())).toISOString()
const grant = mcpTemporaryGrantSchema.parse({ id: grantId, startsAt: new Date(Date.now() - 1000).toISOString(), expiresAt, scope: { serverIds: [], operationIds: [], allowGlobal: false } })
const allowed = connection.policy.scope.operationIds.includes(capabilitiesOperation)
const grant = mcpTemporaryGrantSchema.parse({ id: grantId, startsAt: new Date(Date.now() - 1000).toISOString(), expiresAt, scope: { serverIds: [], operationIds: allowed ? [capabilitiesOperation] : [], allowGlobal: false } })
await client.query("INSERT INTO mcp_credentials(id,connection_id,digest,kind,expires_at,audience,client_id) VALUES($1,$2,$3,'access',$4,$5,'mcp-readiness-probe')", [credentialId, connectionId, tokenDigest(token), expiresAt, canonicalMcpUrl().href])
await client.query("INSERT INTO mcp_grants(id,connection_id,payload) VALUES($1,$2,$3::jsonb)", [grantId, connectionId, JSON.stringify({ ...grant, credentialId })])
await validatePrincipal({ connectionId, credentialId, credentialExpiresAt: expiresAt }, client)
await audit(client, actor.id, "connection.handshake.start", connectionId, { credentialId, expiresAt })
return allowed
})
let completed = false
try { const result = await work(token); completed = true; return result }
try { const result = await work(token, readCapabilities); completed = true; return result }
finally {
await transaction(async client => {
await client.query("DELETE FROM mcp_grants WHERE id=$1 AND connection_id=$2", [grantId, connectionId])
@@ -45,11 +51,12 @@ export async function withMcpProbeCredential<T>(connectionId: string, actor: Aut
}
/** One deadline covers negotiation, metadata and HTTP response bodies. */
export async function runMcpProtocolProbe(endpoint: URL, token: string, timeoutMs = 8000): Promise<ProtocolProbe> {
export async function runMcpProtocolProbe(endpoint: URL, token: string, { timeoutMs = 8000, readCapabilities = false } = {}): Promise<ProtocolProbe> {
const started = performance.now(), controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
const client = new Client({ name: "MikrotikManager readiness", version: "1.0.0" })
const result = (status: ProtocolProbe["status"], reason?: string): ProtocolProbe => ({ status, reason, elapsedMs: Math.round(performance.now() - started), credentialType: "temporary-no-operations", clientCredentialsVerified: false })
let capabilitiesRead: ProtocolProbe["capabilitiesRead"] = "skipped"
const result = (status: ProtocolProbe["status"], reason?: string): ProtocolProbe => ({ status, reason, elapsedMs: Math.round(performance.now() - started), credentialType: readCapabilities ? "temporary-capabilities-read" : "temporary-no-operations", capabilitiesRead, clientCredentialsVerified: false })
try {
const transport = new StreamableHTTPClientTransport(endpoint, { requestInit: { headers: { Authorization: `Bearer ${token}` }, redirect: "error" }, redirectPolicy: "follow",
fetch: async (url, init) => {
@@ -67,13 +74,22 @@ export async function runMcpProtocolProbe(endpoint: URL, token: string, timeoutM
} })
await client.connect(transport, { timeout: timeoutMs })
const tools = await client.listTools({}, { timeout: timeoutMs })
if (tools.tools.length !== 0 || tools.nextCursor) return result("failed", "SCOPE_NOT_ENFORCED")
const expectedTools = readCapabilities ? [capabilitiesTool] : []
if (tools.nextCursor || tools.tools.length !== expectedTools.length || tools.tools.some((tool, index) => tool.name !== expectedTools[index])) return result("failed", "SCOPE_NOT_ENFORCED")
if (readCapabilities) {
capabilitiesRead = "failed"
const call = await client.callTool({ name: capabilitiesTool, arguments: {} }, { timeout: timeoutMs })
const output = (call.structuredContent as { result?: { schemaVersion?: unknown; operations?: unknown } } | undefined)?.result
const operations = output?.operations
if (call.isError || output?.schemaVersion !== 1 || !Array.isArray(operations) || operations.length !== 1 || operations[0]?.id !== capabilitiesOperation || operations[0]?.toolName !== capabilitiesTool) return result("failed", "CAPABILITIES_READ_FAILED")
capabilitiesRead = "passed"
}
return result("passed")
} catch { return result("failed", controller.signal.aborted ? "TIMEOUT" : "TRANSPORT_FAILED") }
} catch { return result("failed", controller.signal.aborted ? "TIMEOUT" : capabilitiesRead === "failed" ? "CAPABILITIES_READ_FAILED" : "TRANSPORT_FAILED") }
finally { clearTimeout(timer); controller.abort(); await client.close().catch(() => {}) }
}
export async function checkMcpProtocol(connectionId: string, actor: AuthUser) {
const endpoint = canonicalMcpUrl()
return withMcpProbeCredential(connectionId, actor, token => runMcpProtocolProbe(endpoint, token))
return withMcpProbeCredential(connectionId, actor, (token, readCapabilities) => runMcpProtocolProbe(endpoint, token, { readCapabilities }))
}
+34 -5
View File
@@ -6,13 +6,16 @@ import { decryptSecret, fingerprint, McpError } from "./security.js"
import { effectiveMcpConnection } from "./store.js"
import { findMcpService } from "./service-registry.js"
import { findMcpOperation } from "./registry.js"
import { readRosSnapshot, resourceById, routerClient, validateRosStep, splitOspfPassiveUpdate, ROS_SNAPSHOT_VERSION, type RosSnapshot, type RosStep } from "./resources.js"
import { readRosSnapshot, resourceById, routerClient, validateRosStep, splitOspfPassiveUpdate, rosSnapshotVersion, type RosSnapshot, type RosStep } from "./resources.js"
import { verifyRosStepSnapshots } from "./ros-step-verification.js"
import type { add } from "./actions.js"
import type { ActionContext, ChangeEvidence, ChangeStep } from "./action-types.js"
import { loadScopedRecoverySource } from "./routing-community-scoped-recovery-source.js"
import { prepareScopedRecovery, registerScopedRecoveryService } from "./routing-community-scoped-recovery.js"
import { reconcileVrfProductionStep } from "../../services/vrf-production-execution.js"
import { needsBridgeVlanRecovery, prepareBridgeVlanRecovery } from "./bridge-vlan-recovery.js"
import { readRpfDependencies } from "./routing-controls.js"
import { verifyHealthChecks } from "./routing-health-checks.js"
async function source(operationId: string, context: ActionContext, allowServices = false) {
const { rows: [row] } = await pool.query("SELECT o.*,p.operation_id AS source_action,p.operation_version AS source_version,p.evidence FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id WHERE o.id=$1 AND o.connection_id=$2", [operationId, context.connection.id])
@@ -86,7 +89,7 @@ export function registerRecoveryActions(register: typeof add) {
},
})
register("operations.recover", "mm_operation_recovery_plan", "Подготовить отдельный обратный план сетевой операции", "network", "mm:network:write", "write", "servers", z.object({ operationId: z.string().uuid() }).strict(), {
version: 7,
version: 8,
target: async (input, context) => {
const { evidence } = await source(String(input.operationId), context, true)
if (evidence.steps.some(step => step.kind === "ros" && step.step.resource === "vrf")) throw new McpError("UNSUPPORTED_CAPABILITY", "VRF не имеет проверенной обратной ревизии; используйте read-only сверку и отдельный approved план", 409)
@@ -106,14 +109,16 @@ export function registerRecoveryActions(register: typeof add) {
const checkpoints = (await pool.query("SELECT step_index,before_sealed,after_sealed FROM mcp_operation_checkpoints WHERE operation_id=$1 ORDER BY step_index DESC", [row.id])).rows
const intended = (await pool.query("SELECT step_index,state,result FROM mcp_operation_steps WHERE operation_id=$1 ORDER BY step_index DESC", [row.id])).rows
if (!intended.length || intended.some(step => step.state !== "verified") || intended.length !== checkpoints.length || checkpoints.some(checkpoint => !checkpoint.after_sealed)) throw new McpError("STATE_CONFLICT", "Нет полного набора подтверждённых снимков; требуется ручная сверка", 409)
if (needsBridgeVlanRecovery(row.source_action, evidence)) return prepareBridgeVlanRecovery({ operationId: row.id, evidence, checkpoints, intended }, context)
const snapshots = new Map<string, RosSnapshot>(), steps: ChangeStep[] = [], objects: ChangeEvidence["objects"] = [...evidence.objects]
const warnings = [`Обратный план операции ${row.id}; не является повтором исходного действия.`, "Восстановление удалённых объектов и сервисов требует их собственного механизма ревизий.", "Сохраняются инварианты Full OSPF/Up BFD и привязка соседств к устройствам, VRF, адресам и интерфейсам; достижимость восстановленной сетевой конфигурации проверяется контрольными пробами."]
for (const checkpoint of checkpoints) {
const original = evidence.steps[checkpoint.step_index]
if (!original || original.kind !== "ros" || original.step.action === "delete") throw new McpError("UNSUPPORTED_CAPABILITY", "Удалённый объект может иметь новые ID и зависимости; используйте проверенную ревизию", 409)
const step = original.step, resource = resourceById(step.resource)
if (!context.connection.policy.scope.serverIds.includes(step.serverId)) throw new McpError("SERVER_DENIED", "Зависимость вне допуска")
const before = JSON.parse(await decryptSecret(checkpoint.before_sealed)) as RosSnapshot, after = JSON.parse(await decryptSecret(checkpoint.after_sealed)) as RosSnapshot
if (before.projectionVersion !== ROS_SNAPSHOT_VERSION || after.projectionVersion !== ROS_SNAPSHOT_VERSION || !before.sourceProof || before.sourceProof !== after.sourceProof || before.fingerprint !== fingerprint(before.rows) || after.fingerprint !== fingerprint(after.rows) || !verifyRosStepSnapshots(step, intended.find(item => item.step_index === checkpoint.step_index)?.result, before, after)) throw new McpError("STATE_CONFLICT", "Исходный переход каталога или источник не подтверждён; обратный план запрещён", 409)
if (before.projectionVersion !== rosSnapshotVersion(step.resource) || after.projectionVersion !== rosSnapshotVersion(step.resource) || !before.sourceProof || before.sourceProof !== after.sourceProof || before.fingerprint !== fingerprint(before.rows) || after.fingerprint !== fingerprint(after.rows) || !verifyRosStepSnapshots(step, intended.find(item => item.step_index === checkpoint.step_index)?.result, before, after)) throw new McpError("STATE_CONFLICT", "Исходный переход каталога или источник не подтверждён; обратный план запрещён", 409)
const key = `${step.serverId}:${step.resource}`
if (!snapshots.has(key)) {
const current = await readRosSnapshot(step.serverId, step.resource, undefined, after.sourceProof)
@@ -143,11 +148,35 @@ export function registerRecoveryActions(register: typeof add) {
const key = `${dependency.serverId}:${dependency.resource}`
if (snapshots.has(key)) continue
const current = await readRosSnapshot(dependency.serverId, dependency.resource, undefined, dependency.sourceProof)
if (current.fingerprint !== dependency.fingerprint || current.sourceProof !== dependency.sourceProof || dependency.projectionVersion !== ROS_SNAPSHOT_VERSION) throw new McpError("STATE_CONFLICT", "Зависимость или источник исходного плана изменились", 409)
if (current.fingerprint !== dependency.fingerprint || current.sourceProof !== dependency.sourceProof || dependency.projectionVersion !== rosSnapshotVersion(dependency.resource)) throw new McpError("STATE_CONFLICT", "Зависимость или источник исходного плана изменились", 409)
snapshots.set(key, current)
}
for (const change of steps) {
if (change.kind !== "ros" || change.step.resource !== "ip_settings" || !change.step.fields["rp-filter"]) continue
const settings = snapshots.get(`${change.step.serverId}:ip_settings`)
const rpf = await readRpfDependencies(change.step.serverId, change.step.fields["rp-filter"], settings?.sourceProof)
for (const snapshot of rpf.snapshots) {
const key = `${snapshot.serverId}:${snapshot.resource}`, previous = snapshots.get(key)
if (previous && (previous.fingerprint !== snapshot.fingerprint || previous.sourceProof !== snapshot.sourceProof)) {
throw new McpError("STATE_CONFLICT", "Зависимость RPF изменилась при подготовке восстановления", 409)
}
snapshots.set(key, snapshot)
}
warnings.push(...rpf.warnings)
}
objects.push(...[...new Set([...snapshots.values()].map(snapshot => snapshot.serverId))].map(serverId => ({ kind: "server", id: String(serverId), serverId })))
return { target: { scope: "servers", serverIds: [...new Set([...snapshots.values()].map(snapshot => snapshot.serverId))] }, objects, snapshots: [...snapshots.values()], steps, ospfOwnerGraph: evidence.ospfOwnerGraph, healthChecks: evidence.healthChecks?.filter(check => check.field === "state" || check.field === "bfd-up"), requiresHuman: true, warnings: [`Обратный план операции ${row.id}; не является повтором исходного действия.`, "Восстановление удалённых объектов и сервисов требует их собственного механизма ревизий.", "Сохраняются инварианты Full OSPF/Up BFD и привязка соседств к устройствам, VRF, адресам и интерфейсам; достижимость восстановленной сетевой конфигурации проверяется контрольными пробами."] }
const healthChecks = evidence.healthChecks?.filter(check => check.field === "state" || check.field === "bfd-up" || check.field === "resolved")
const currentSnapshots = [...snapshots.values()]
const serverIds = [...new Set(currentSnapshots.map(snapshot => snapshot.serverId))]
const nextHopChecks = healthChecks?.filter(check => check.field === "resolved")
if (nextHopChecks?.length) await verifyHealthChecks({ healthChecks: nextHopChecks, snapshots: currentSnapshots }, serverIds, false, async () => {
if (context.signal?.aborted) throw new McpError("READ_CANCELLED", "Подготовка обратного плана отменена", 409)
const current = await effectiveMcpConnection(context.principal)
if (fingerprint(current.policy) !== fingerprint(context.connection.policy) || fingerprint(current.owner_permissions) !== fingerprint(context.connection.owner_permissions)) {
throw new McpError("RESET_REQUIRED", "Допуск восстановления изменился", 409)
}
})
return { target: { scope: "servers", serverIds }, objects, snapshots: currentSnapshots, steps, ospfOwnerGraph: evidence.ospfOwnerGraph, healthChecks, requiresHuman: true, warnings }
},
})
}
+53 -10
View File
@@ -5,14 +5,19 @@ import { eq } from "drizzle-orm"
import { db } from "../../db/index.js"
import { servers } from "../../db/schema.js"
import { MikrotikClient } from "../../services/mikrotik.js"
import { McpError, fingerprint, privateSecretFingerprint } from "./security.js"
import { readRouterOsSnapshotGet } from "../../services/routeros-snapshot-read.js"
import { McpError, fingerprint, privateFingerprinter, privateSecretFingerprint } from "./security.js"
import { readOspfEffectiveDisabled } from "../../services/ospf-effective-disabled.js"
import { greNativeFieldsSchema } from "../../services/gre-mutation-input.js"
import { canonicalVrfMembers } from "../../services/vrf-members.js"
import { canonicalBridgeVlanValue } from "../../services/bridge-vlan-values.js"
const text = z.string().max(1000)
const name = z.string().trim().min(1).max(100)
const bool = z.boolean()
const vlanIds = z.string().min(1).max(16384).refine(value => {
try { canonicalBridgeVlanValue("vlan-ids", value); return true } catch { return false }
}, "Требуется непересекающийся список VLAN ID/диапазонов 1..4094")
const integer = (min: number, max: number) => z.number().int().min(min).max(max)
const common = { name: name.optional(), comment: text.optional(), disabled: bool.optional() }
const ipv4 = name.refine(value => isIP(value) === 4, "Требуется IPv4 router/area ID")
@@ -31,19 +36,21 @@ const route = {
export interface RosResourceSpec {
id: string; path: string; title: string; permission: string; fields: z.ZodObject;
create: boolean; remove: boolean; requiredOnCreate?: string[]; readOnly?: boolean
/** Native configuration sealed for drift checks, never accepted as writes. */
observeFields?: readonly string[]
}
export const rosResources: RosResourceSpec[] = [
{ id: "routing_filter", path: "/routing/filter/rule", title: "Фильтры маршрутизации", permission: "mm:filters", fields: z.object({ chain: text.optional(), rule: text.optional(), comment: text.optional(), disabled: bool.optional() }).strict(), create: false, remove: false, readOnly: true },
{ id: "ethernet", path: "/interface/ethernet", title: "Ethernet", permission: "mm:network", fields: z.object({ ...common, mtu: integer(68, 65535).optional(), "auto-negotiation": bool.optional() }).strict(), create: false, remove: false },
{ id: "bridge", path: "/interface/bridge", title: "Bridge", permission: "mm:network", fields: z.object({ ...common, mtu: integer(68, 65535).optional(), "vlan-filtering": bool.optional(), pvid: integer(1, 4094).optional(), "protocol-mode": z.enum(["none", "stp", "rstp", "mstp"]).optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name"] },
{ id: "bridge", path: "/interface/bridge", title: "Bridge", permission: "mm:network", fields: z.object({ ...common, mtu: integer(68, 65535).optional(), "vlan-filtering": bool.optional(), pvid: integer(1, 4094).optional(), "frame-types": z.enum(["admit-all", "admit-only-vlan-tagged", "admit-only-untagged-and-priority-tagged"]).optional(), "ingress-filtering": bool.optional(), "protocol-mode": z.enum(["none", "stp", "rstp", "mstp"]).optional() }).strict(), observeFields: ["mvrp"], create: true, remove: true, requiredOnCreate: ["name"] },
{ id: "bridge_port", path: "/interface/bridge/port", title: "Порты bridge", permission: "mm:network", fields: z.object({ bridge: name.optional(), interface: name.optional(), pvid: integer(1, 4094).optional(), "ingress-filtering": bool.optional(), "frame-types": z.enum(["admit-all", "admit-only-vlan-tagged", "admit-only-untagged-and-priority-tagged"]).optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["bridge", "interface"] },
{ id: "bridge_vlan", path: "/interface/bridge/vlan", title: "Bridge VLAN membership", permission: "mm:network", fields: z.object({ bridge: name.optional(), "vlan-ids": name.optional(), tagged: text.optional(), untagged: text.optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["bridge", "vlan-ids"] },
{ id: "bridge_vlan", path: "/interface/bridge/vlan", title: "Bridge VLAN membership", permission: "mm:network", fields: z.object({ bridge: name.optional(), "vlan-ids": vlanIds.optional(), tagged: text.optional(), untagged: text.optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["bridge", "vlan-ids"] },
{ id: "vlan", path: "/interface/vlan", title: "VLAN", permission: "mm:network", fields: z.object({ ...common, interface: name.optional(), "vlan-id": integer(1, 4094).optional(), mtu: integer(68, 65535).optional(), "use-service-tag": bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name", "interface", "vlan-id"] },
{ id: "ipv4_address", path: "/ip/address", title: "IPv4-адреса", permission: "mm:network", fields: z.object({ address: name.optional(), interface: name.optional(), network: name.optional(), comment: text.optional(), disabled: bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["address", "interface"] },
{ id: "ipv6_address", path: "/ipv6/address", title: "IPv6-адреса", permission: "mm:network", fields: z.object({ address: name.optional(), interface: name.optional(), advertise: bool.optional(), comment: text.optional(), disabled: bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["address", "interface"] },
{ id: "ipv4_route", path: "/ip/route", title: "Маршруты IPv4", permission: "mm:network", fields: z.object(route).strict(), create: true, remove: true, requiredOnCreate: ["dst-address"] },
{ id: "ipv6_route", path: "/ipv6/route", title: "Маршруты IPv6", permission: "mm:network", fields: z.object(route).strict(), create: true, remove: true, requiredOnCreate: ["dst-address"] },
{ id: "routing_table", path: "/routing/table", title: "Таблицы маршрутизации", permission: "mm:network", fields: z.object({ name: name.optional(), fib: bool.optional(), disabled: bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name"] },
{ id: "routing_table", path: "/routing/table", title: "Таблицы маршрутизации", permission: "mm:network", fields: z.object({ name: name.optional(), fib: bool.optional(), disabled: bool.optional() }).strict(), observeFields: ["invalid"], create: true, remove: true, requiredOnCreate: ["name"] },
{ id: "routing_rule", path: "/routing/rule", title: "Правила маршрутизации", permission: "mm:network", fields: z.object({ "src-address": text.optional(), "dst-address": text.optional(), interface: text.optional(), action: z.enum(["lookup", "lookup-only-in-table", "drop", "unreachable"]).optional(), table: name.optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["action"] },
{ id: "vrf", path: "/ip/vrf", title: "VRF", permission: "mm:network", fields: z.object({ name: name.optional(), interfaces: text.optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name", "interfaces"] },
{ id: "ip_settings", path: "/ip/settings", title: "RPF и IPv4 hash-policy", permission: "mm:network", fields: z.object({ "rp-filter": z.enum(["no", "loose", "strict"]).optional(), "ipv4-multipath-hash-policy": z.enum(["l3", "l4", "l3-inner"]).optional() }).strict(), create: false, remove: false },
@@ -70,7 +77,34 @@ export const rosResources: RosResourceSpec[] = [
export type ConfigurationRow = Record<string, unknown>
export interface RosStep { serverId: number; resource: string; action: "create" | "update" | "delete"; id?: string; fields: Record<string, string>; placeBeforeId?: string; expectedCost?: number; expectedServerProof?: string }
export const ROS_SNAPSHOT_VERSION = 3
export const rosSnapshotVersion = (resource: string): number => ["bridge", "bridge_port", "bridge_vlan", "routing_table"].includes(resource) ? 4 : ROS_SNAPSHOT_VERSION
export interface RosSnapshot { serverId: number; resource: string; rows: ConfigurationRow[]; fingerprint: string; projectionVersion?: number; sourceProof?: string }
const routingPolicyResources = new Set(["routing_rule", "ip_filter", "ip_nat", "ip_mangle", "ip_raw", "ipv6_filter", "ipv6_nat", "ipv6_mangle", "ipv6_raw"])
const routingPolicyPrefix = "routing_policy:"
const routingPolicyCounters = new Set(["bytes", "packets"])
export function isRoutingPolicySnapshot(resource: string) {
return resource.startsWith(routingPolicyPrefix) && routingPolicyResources.has(resource.slice(routingPolicyPrefix.length))
}
export function readRoutingPolicySnapshot(serverId: number, resource: string, expectedSourceProof?: string) {
if (!routingPolicyResources.has(resource)) throw new McpError("UNSUPPORTED_CAPABILITY", "Неизвестный каталог политики маршрутизации", 409)
return readRosSnapshot(serverId, routingPolicyPrefix + resource, undefined, expectedSourceProof)
}
async function routingPolicyRows(resource: string, payload: unknown): Promise<ConfigurationRow[]> {
if (!Array.isArray(payload)) throw new McpError("STATE_CONFLICT", "Каталог политики маршрутизации не подтверждён", 409)
const identify = await privateFingerprinter(`mcp-routing-policy-v1:${resource}`)
const ids = new Set<string>()
return payload.map(row => {
const id = row?.[".id"]
if (!row || typeof row !== "object" || Array.isArray(row) || typeof id !== "string" || !id.trim() || ids.has(id)) {
throw new McpError("STATE_CONFLICT", "Неоднозначные ID каталога политики маршрутизации", 409)
}
ids.add(id)
// Seal the native rule, including selectors outside the write schema.
const configuration = Object.fromEntries(Object.entries(row).filter(([name]) => resource === routingPolicyPrefix + "routing_rule" || !routingPolicyCounters.has(name)))
return { ".id": id, action: row.action ?? null, disabled: row.disabled ?? null, proof: identify(fingerprint(configuration)) }
})
}
export function resourceById(id: string): RosResourceSpec {
const resource = rosResources.find(item => item.id === id)
if (!resource) throw new McpError("UNSUPPORTED_CAPABILITY", "Неизвестный тип сетевого объекта", 400)
@@ -93,13 +127,21 @@ export function configurationRows(resource: RosResourceSpec, payload: unknown):
// RouterOS creates/removes IPv6 link-local rows with interface runtime state.
// They cannot be configured independently and must not invalidate a sealed
// inverse plan when its own GRE is disabled. Explicit addresses remain sealed.
const configuration = rows.filter(row => !(resource.id === "ipv6_address" && [true, "true"].includes((row as ConfigurationRow).dynamic as boolean | string) && /^fe80:/i.test(String((row as ConfigurationRow).address))))
const configuration = rows.filter(row => !(resource.id === "ipv6_address" && [true, "true"].includes((row as ConfigurationRow).dynamic as boolean | string) && /^fe80:/i.test(String((row as ConfigurationRow).address)))
// These rows are generated from bridge/port PVID, not independently
// configurable VLAN entries. Their inputs remain sealed in bridge/ports.
&& !(resource.id === "bridge_vlan" && [true, "true", "yes"].includes((row as ConfigurationRow).dynamic as boolean | string)))
const selectedRows = configuration.map(row => {
const selected = Object.fromEntries(Object.entries(row as ConfigurationRow).filter(([key]) => [".id", "dynamic", "builtin", "default", ...fields].includes(key)))
const selected = Object.fromEntries(Object.entries(row as ConfigurationRow).filter(([key]) => [".id", "dynamic", "builtin", "default", ...fields, ...(resource.observeFields ?? [])].includes(key)))
// RouterOS 7.24.5 returns an explicit empty fib flag for non-FIB tables.
// Normalize only this observed field; missing values must remain unknown.
if (resource.id === "routing_table" && selected.fib === "") selected.fib = "false"
if (resource.id === "vrf" && selected.interfaces !== undefined) selected.interfaces = canonicalVrfMembers(selected.interfaces)
if (["bridge_port", "bridge_vlan"].includes(resource.id) && selected.comment === undefined) selected.comment = ""
if (resource.id === "bridge_vlan") {
try { for (const field of ["vlan-ids", "tagged", "untagged"]) selected[field] = canonicalBridgeVlanValue(field, selected[field]) }
catch { throw new McpError("STATE_CONFLICT", "Недостоверная конфигурация VLAN membership", 409) }
}
return selected
})
// Reordering an ordered catalog changes configuration even with unchanged rows.
@@ -107,7 +149,8 @@ export function configurationRows(resource: RosResourceSpec, payload: unknown):
}
export async function readRosSnapshot(serverId: number, resourceId: string, beforeRead: () => Promise<unknown> = async () => {}, expectedSourceProof?: string): Promise<RosSnapshot> {
await beforeRead()
const resource = resourceById(resourceId), { client, server } = await routerClient(serverId)
const policy = isRoutingPolicySnapshot(resourceId)
const resource = resourceById(policy ? resourceId.slice(routingPolicyPrefix.length) : resourceId), { client, server } = await routerClient(serverId)
const sourceProof = await rosSnapshotSourceProof(server)
if (expectedSourceProof !== undefined && expectedSourceProof !== sourceProof) throw new McpError("STATE_CONFLICT", "Источник сетевого снимка изменился до опроса", 409)
const checkSource = async () => {
@@ -118,10 +161,10 @@ export async function readRosSnapshot(serverId: number, resourceId: string, befo
const payload = resourceId === "ospf_template" ? await client.getOspfInterfaceTemplates(checkSource) : ["ospf_instance", "ospf_area", "bgp_instance", "bgp_connection"].includes(resourceId) ? await readOspfEffectiveDisabled(await client.get<unknown>(resource.path), async id => {
await checkSource()
return client.post(resource.path + "/get", { ".id": id, "value-name": "disabled" })
}) : await client.get<unknown>(resource.path)
}) : await readRouterOsSnapshotGet<unknown>({ client, path: resource.path, assertCurrent: checkSource })
await checkSource()
const rows = configurationRows(resource, payload)
return { serverId, resource: resourceId, rows, fingerprint: fingerprint(rows), projectionVersion: ROS_SNAPSHOT_VERSION, sourceProof }
const rows = policy ? await routingPolicyRows(resourceId, payload) : configurationRows(resource, payload)
return { serverId, resource: resourceId, rows, fingerprint: fingerprint(rows), projectionVersion: rosSnapshotVersion(resourceId), sourceProof }
}
export async function rosSnapshotSourceProof(server: Awaited<ReturnType<typeof routerClient>>["server"]): Promise<string> {
return privateSecretFingerprint(fingerprint({ id: server.id, enabled: server.enabled, host: server.host, port: server.port, useSsl: server.useSsl, verifySsl: server.verifySsl, username: server.username, password: server.password, epoch: server.routingEndpointEpoch }), "mcp-ros-snapshot-source-v3")
@@ -60,6 +60,16 @@ test("non-JSON detail cannot overwrite the already applied receipt", () => {
output.result = output
assert.equal(projected(accepted(output, "operation")).state, "applied")
})
test("compact receipt keeps a known pre-dispatch stop and its verified prefix", () => {
for (const [state, confirmedWrites] of [["rejected", 0], ["partial", 2]] as const) {
const result = { failedStepDispatched: false, confirmedWrites }
const output = { id, plan_id: plan, state, verification: "inconclusive", error_code: "STATE_CONFLICT" }
assert.deepEqual(projected(accepted({ ...output, result: { ...result, detail: 1n } }, "operation")), { ...output, result })
for (const invalid of [{ ...result, confirmedWrites: -1 }, { ...result, failedStepDispatched: true }]) {
assert.deepEqual(projected(accepted({ ...output, result: { ...invalid, detail: 1n } }, "operation")), output)
}
}
})
test("prepare omission keeps real plan/state, requests review, never invents executionAvailable", () => {
const result = accepted({ planId: plan, state: "approved", expiresAt: "2026-10-31T12:00:00Z", executionAvailable: true, changes: "x".repeat(MCP_CALL_RESULT_BYTES) }, "plan")
assert.equal(result.isError, undefined)
+7
View File
@@ -36,6 +36,13 @@ function project(output: unknown, projection: ResultProjection): Record<string,
const receipt: Record<string, unknown> = { id, plan_id: planId, state, verification }
const errorCode = data(output, "error_code")
if (errorCode === null || short(errorCode)) receipt.error_code = errorCode
const outcome = data(output, "result")
const confirmedWrites = data(outcome, "confirmedWrites")
if ((state === "partial" || state === "rejected") && data(outcome, "failedStepDispatched") === false &&
typeof confirmedWrites === "number" && Number.isSafeInteger(confirmedWrites) &&
(state === "partial" ? confirmedWrites > 0 : confirmedWrites === 0)) {
receipt.result = { failedStepDispatched: false, confirmedWrites }
}
return receipt
}
const planId = data(output, "planId"), state = data(output, "state")
+3 -2
View File
@@ -1,9 +1,9 @@
import assert from "node:assert/strict"
import { createRosPrefix } from "./ros-prefix.js"
import { fingerprint, McpError } from "./security.js"
import { ROS_SNAPSHOT_VERSION, type RosSnapshot, type RosStep, type ConfigurationRow } from "./resources.js"
import { rosSnapshotVersion, type RosSnapshot, type RosStep, type ConfigurationRow } from "./resources.js"
const snapshot = (resource: string, rows: ConfigurationRow[], sourceProof = "a".repeat(64)): RosSnapshot => ({ serverId: 1, resource, rows, fingerprint: fingerprint(rows), projectionVersion: ROS_SNAPSHOT_VERSION, sourceProof })
const snapshot = (resource: string, rows: ConfigurationRow[], sourceProof = "a".repeat(64)): RosSnapshot => ({ serverId: 1, resource, rows, fingerprint: fingerprint(rows), projectionVersion: rosSnapshotVersion(resource), sourceProof })
const old = snapshot("ip_filter", [{ ".id": "*a", chain: "forward", action: "accept", comment: "old" }, { ".id": "*b", chain: "forward", action: "drop", comment: "keep" }])
const dependency = snapshot("routing_table", [{ ".id": "*main", name: "main", fib: "true" }])
const changed = snapshot("ip_filter", [{ ...old.rows[0], comment: "new" }, old.rows[1]])
@@ -15,6 +15,7 @@ const isFailed = (error: unknown) => error instanceof McpError && error.code ===
for (const invalid of [{ ...old, sourceProof: undefined }, { ...old, projectionVersion: 1 }, { ...old, projectionVersion: 2 }, { ...old, fingerprint: "forged" }]) assert.throws(() => createRosPrefix([invalid]), error => error instanceof McpError && error.code === "PLAN_EXPIRED")
assert.throws(() => createRosPrefix([old, changed]), isConflict)
assert.throws(() => createRosPrefix([{ ...dependency, projectionVersion: 3 }]), { code: "PLAN_EXPIRED" })
const prefix = createRosPrefix([old, dependency, structuredClone(old)], read)
await prefix.check()
actual.set(old.resource, changed)
+2 -2
View File
@@ -1,11 +1,11 @@
import { fingerprint, McpError } from "./security.js"
import { readRosSnapshot, ROS_SNAPSHOT_VERSION, type RosSnapshot, type RosStep } from "./resources.js"
import { readRosSnapshot, rosSnapshotVersion, type RosSnapshot, type RosStep } from "./resources.js"
import { verifyRosStepSnapshots } from "./ros-step-verification.js"
import { acknowledgedCreateId } from "./native-create-identity.js"
const key = (snapshot: Pick<RosSnapshot, "serverId" | "resource">) => `${snapshot.serverId}:${snapshot.resource}`
function valid(snapshot: RosSnapshot): boolean {
if (snapshot.projectionVersion !== ROS_SNAPSHOT_VERSION || typeof snapshot.sourceProof !== "string" || !/^[a-f0-9]{64}$/.test(snapshot.sourceProof) || !Array.isArray(snapshot.rows) || snapshot.fingerprint !== fingerprint(snapshot.rows)) return false
if (snapshot.projectionVersion !== rosSnapshotVersion(snapshot.resource) || typeof snapshot.sourceProof !== "string" || !/^[a-f0-9]{64}$/.test(snapshot.sourceProof) || !Array.isArray(snapshot.rows) || snapshot.fingerprint !== fingerprint(snapshot.rows)) return false
if (["ip_settings", "ipv6_settings"].includes(snapshot.resource)) return snapshot.rows.length === 1 && !!snapshot.rows[0] && typeof snapshot.rows[0] === "object" && !Array.isArray(snapshot.rows[0])
const ids = snapshot.rows.map(acknowledgedCreateId)
return ids.every(id => id !== undefined) && new Set(ids).size === ids.length
@@ -1,7 +1,7 @@
import assert from "node:assert/strict"
import { configurationRows, isOrderedRosResource, resourceById, type ConfigurationRow, type RosSnapshot, type RosStep } from "./resources.js"
import { configurationRows, isOrderedRosResource, resourceById, rosSnapshotVersion, type ConfigurationRow, type RosSnapshot, type RosStep } from "./resources.js"
import { fingerprint } from "./security.js"
import { verifyRosStepSnapshots } from "./ros-step-verification.js"
import { proveRosDriftExpectation, verifyRosStepSnapshots } from "./ros-step-verification.js"
import { readOspfTemplateFlags } from "../../services/ospf-template-flags.js"
function snapshot(resource: string, rows: ConfigurationRow[]): RosSnapshot {
@@ -106,3 +106,55 @@ assert.equal(raw[0].opaque, "outside-projection")
assert.equal(verifyRosStepSnapshots(update, {}, { ...snapshot("ip_filter", [a]), serverId: 2 }, snapshot("ip_filter", [updated])), false)
assert.equal(verifyRosStepSnapshots(update, {}, snapshot("bridge", [a]), snapshot("ip_filter", [updated])), false)
console.log("ROS projected whole-catalog verifier: acknowledged assigned ID, exact membership/field conservation, 12 ordered catalogs including OSPF templates, singleton settings, schema booleans, effective OSPF unset, immutable inputs")
const sourceProof = "a".repeat(64)
function driftSnapshot(resource: string, rows: ConfigurationRow[]): RosSnapshot {
return { ...snapshot(resource, rows), projectionVersion: rosSnapshotVersion(resource), sourceProof }
}
function expectDrift(step: RosStep, receipt: unknown, before: ConfigurationRow[], after: ConfigurationRow[]) {
const expectation = proveRosDriftExpectation(step, receipt, driftSnapshot(step.resource, before), driftSnapshot(step.resource, after))
assert.ok(expectation)
assert.equal(expectation.sourceProof, sourceProof)
return expectation
}
const settingsDrift = expectDrift(settings, {}, [s0], [s1])
assert.equal(settingsDrift.nativeId, undefined)
assert.equal(settingsDrift.matches(driftSnapshot("ip_settings", [{ ...s1, "ipv4-multipath-hash-policy": "l4" }])), true)
assert.equal(settingsDrift.matches(driftSnapshot("ip_settings", [s0])), false)
for (const rows of [[], [s1, s1], [{}], [{ "rp-filter": "unknown" }], [{ "rp-filter": null }], [{ "rp-filter": ["loose"] }]]) {
assert.equal(settingsDrift.matches(driftSnapshot("ip_settings", rows)), undefined)
}
const verifiedSettings = driftSnapshot("ip_settings", [s1])
assert.equal(settingsDrift.matches({ ...verifiedSettings, sourceProof: "b".repeat(64) }), undefined)
assert.equal(settingsDrift.matches({ ...verifiedSettings, serverId: 2 }), undefined)
assert.equal(settingsDrift.matches({ ...verifiedSettings, fingerprint: "b".repeat(64) }), undefined)
assert.equal(proveRosDriftExpectation(settings, {}, snapshot("ip_settings", [s0]), verifiedSettings), undefined, "Legacy evidence without a source/version cannot prove drift")
assert.equal(proveRosDriftExpectation(settings, {}, driftSnapshot("ip_settings", [s0]), { kind: "vrf-production-private-checkpoint-v1" }), undefined)
assert.equal(proveRosDriftExpectation(settings, {}, driftSnapshot("ip_settings", [s0]), { ...verifiedSettings, sourceProof: "b".repeat(64) }), undefined)
assert.equal(proveRosDriftExpectation(settings, undefined, driftSnapshot("ip_settings", [s0]), verifiedSettings), undefined)
const createDrift = expectDrift(create, ack, [a, b], [a, b, c])
assert.equal(createDrift.nativeId, "*C")
assert.equal(createDrift.matches(driftSnapshot("ip_filter", [{ ...b, comment: "unrelated" }, c])), true)
assert.equal(createDrift.matches(driftSnapshot("ip_filter", [a, b])), false)
assert.equal(createDrift.matches(driftSnapshot("ip_filter", [a, b, c, c])), undefined)
assert.equal(proveRosDriftExpectation(create, {}, driftSnapshot("ip_filter", [a, b]), driftSnapshot("ip_filter", [a, b, c])), undefined)
const updateDrift = expectDrift(update, {}, [a, b], [updated, b])
assert.equal(updateDrift.nativeId, "*A")
assert.equal(updateDrift.matches(driftSnapshot("ip_filter", [{ ...updated, comment: "unrelated" }, c])), true)
assert.equal(updateDrift.matches(driftSnapshot("ip_filter", [a, c])), false)
assert.equal(updateDrift.matches(driftSnapshot("ip_filter", [b, c])), false)
const booleanDrift = expectDrift(booleanStep, {}, [a], [a])
assert.equal(booleanDrift.matches(driftSnapshot("ip_filter", [{ ...a, disabled: "no" }])), true)
assert.equal(booleanDrift.matches(driftSnapshot("ip_filter", [{ ...a, disabled: "yes" }])), false)
assert.equal(booleanDrift.matches(driftSnapshot("ip_filter", [{ ...a, disabled: "" }])), undefined)
const deleteDrift = expectDrift(remove, null, [a, b], [b])
assert.equal(deleteDrift.nativeId, "*A")
assert.equal(deleteDrift.matches(driftSnapshot("ip_filter", [{ ...b, comment: "unrelated" }, c])), true)
assert.equal(deleteDrift.matches(driftSnapshot("ip_filter", [{ ...a, disabled: "no" }, c])), false)
assert.equal(deleteDrift.matches(driftSnapshot("ip_filter", [{ ...a, chain: "output", comment: "a different rule reused this ID" }, b])), undefined)
console.log("ROS drift proof: bound source and verified transition, scoped typed fields, unknown observations and delete ID reuse")
@@ -1,6 +1,8 @@
import { isDeepStrictEqual } from "node:util"
import { fingerprint } from "./security.js"
import { acknowledgedCreateId } from "./native-create-identity.js"
import { isOrderedRosResource, resourceById, type ConfigurationRow, type RosResourceSpec, type RosSnapshot, type RosStep } from "./resources.js"
import { canonicalBridgeVlanValue } from "../../services/bridge-vlan-values.js"
import { isOrderedRosResource, resourceById, rosSnapshotVersion, type ConfigurationRow, type RosResourceSpec, type RosSnapshot, type RosStep } from "./resources.js"
function booleanValue(value: unknown): unknown {
if ([true, "true", "yes"].includes(value as boolean | string)) return true
@@ -23,6 +25,9 @@ function requested(resource: RosResourceSpec, fields: RosStep["fields"], row: Co
const schema = resource.fields.shape[key]
if (!schema || typeof value !== "string" || !Object.hasOwn(row, key) || row[key] === undefined || row[key] === null) return false
if (schema.safeParse(true).success) return ["true", "false"].includes(value) && booleanValue(row[key]) === (value === "true")
if (resource.id === "bridge_vlan" && ["vlan-ids", "tagged", "untagged"].includes(key)) {
try { return canonicalBridgeVlanValue(key, row[key]) === canonicalBridgeVlanValue(key, value) } catch { return false }
}
return String(row[key]) === value
})
}
@@ -88,3 +93,66 @@ export function verifyRosStepSnapshots(step: RosStep, result: unknown, before: R
// already prove exact membership. Ordered catalogs must also retain position.
return !isOrderedRosResource(step.resource) || isDeepStrictEqual(expectedIds, [...current.keys()])
}
const singletonRosResource = (resource: string) => resource === "ip_settings" || resource === "ipv6_settings"
function projectedSnapshot(value: unknown, step: RosStep): value is RosSnapshot & { sourceProof: string } {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
const snapshot = value as RosSnapshot
if (snapshot.serverId !== step.serverId || snapshot.resource !== step.resource
|| snapshot.projectionVersion !== rosSnapshotVersion(step.resource)
|| typeof snapshot.sourceProof !== "string" || !/^[a-f0-9]{64}$/.test(snapshot.sourceProof)
|| !Array.isArray(snapshot.rows) || snapshot.fingerprint !== fingerprint(snapshot.rows)) return false
if (!singletonRosResource(step.resource)) return indexed(snapshot.rows) !== undefined
const row = snapshot.rows[0]
return snapshot.rows.length === 1 && !!row && typeof row === "object" && !Array.isArray(row)
}
interface RosDriftExpectation {
nativeId: string | undefined
sourceProof: string
matches(current: unknown): boolean | undefined
}
/** Bind the drift expectation to an acknowledged and verified transition.
* The caller distinguishes a stored JSON null (native 204) from SQL NULL. */
export function proveRosDriftExpectation(step: RosStep, receipt: unknown, before: unknown, after: unknown): RosDriftExpectation | undefined {
if (receipt === undefined || !projectedSnapshot(before, step) || !projectedSnapshot(after, step)
|| before.sourceProof !== after.sourceProof || !verifyRosStepSnapshots(step, receipt, before, after)) return undefined
const resource = resourceById(step.resource)
const nativeId = step.action === "create" ? acknowledgedCreateId(receipt) : step.id
const singleton = singletonRosResource(step.resource)
const deleted = step.action === "delete" ? before.rows.find(row => row[".id"] === nativeId) : undefined
const sourceProof = after.sourceProof
return {
nativeId,
sourceProof,
matches(current) {
if (!projectedSnapshot(current, step) || current.sourceProof !== sourceProof) return undefined
const row = singleton ? current.rows[0] : current.rows.find(item => item[".id"] === nativeId)
if (deleted) {
if (!row) return true
// A different row at a reused ID does not prove the original row returned.
return conserved(resource, deleted, row) ? false : undefined
}
if (!row) return false
let complete = true
for (const [key, expected] of Object.entries(step.fields)) {
const schema = resource.fields.shape[key]
const raw = row[key]
if (!schema || !Object.hasOwn(row, key) || raw === undefined || raw === null) {
complete = false
continue
}
const boolean = schema.safeParse(true).success
const valid = boolean ? typeof booleanValue(raw) === "boolean"
: (typeof raw === "string" || typeof raw === "number") && (
schema.safeParse(raw).success || typeof raw === "string" && /^\d+$/.test(raw) && schema.safeParse(Number(raw)).success
)
if (!valid) complete = false
else if (!requested(resource, { [key]: expected }, row)) return false
}
return complete ? true : undefined
},
}
}
+60 -13
View File
@@ -1,5 +1,5 @@
import { prepareRoutingMainTransition, beginRoutingMainTransition, finishRoutingMainTransition, type RoutingMainTransitionAttempt } from "./routing-main-transition.js"
import { readRoutingObservations } from "./routing-observations.js"
import { queryRoutingObservations, readRoutingObservations } from "./routing-observations.js"
import { isIP } from "node:net"
import { hasPermission } from "../../lib/permissions.js"
import { effectiveMcpConnection } from "./store.js"
@@ -25,10 +25,45 @@ import { resolveRoutingProfileAuthority, assertRoutingProfileAuthority, type Rou
import { createRoutingSourceContext, readRoutingEndpoints } from "./routing-observation-source.js"
const sid = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
const historySelector = z.string().trim().min(1).max(100)
const routingHistorySchema = z.object({
serverId: sid.optional(), srcServerId: sid.optional(), dstServerId: sid.optional(),
srcInterface: historySelector.optional(), dstInterface: historySelector.optional(),
family: z.enum(["ipv4", "ipv6"]).optional(), table: historySelector.optional(),
limit: z.number().int().min(1).max(100).default(50),
cursor: z.object({ recordedAt: z.string().datetime({ precision: 6 }), id: z.string().uuid() }).strict().optional(),
}).strict()
const ospfOptimizerSchema = z.object({ serverId: sid, family: z.enum(["ipv4", "ipv6"]).default("ipv4"), table: z.string().min(1).default("main"), ecmpPaths: z.number().int().min(1).max(64).default(1), pingWeight: z.number().min(0).max(100).default(60), maxLossPct: z.number().min(0).max(100).default(1) }).strict()
export { readRoutingObservations } from "./routing-observations.js"
const profileTarget = (profile: z.infer<typeof routeAiProfileSchema>) => [...new Set([profile.serverId, ...profile.candidates.flatMap(candidate => [candidate.jumpHostId, candidate.exitNodeId])])]
const routeAnalysisSchema = z.object({
profile: routeAiProfileSchema,
offset: z.number().int().min(0).max(100).default(0),
limit: z.number().int().min(1).max(100).default(20),
eligibility: z.enum(["all", "eligible", "ineligible"]).default("all"),
}).strict()
const routeComparisonSchema = z.object({ profile: routeAiProfileSchema, candidateIds: z.array(z.string().min(1).max(100)).min(2).max(8) }).strict().superRefine(({ profile, candidateIds }, context) => {
const known = new Set(profile.candidates.map(candidate => candidate.id))
if (new Set(candidateIds).size !== candidateIds.length || candidateIds.some(id => !known.has(id))) {
context.addIssue({ code: "custom", path: ["candidateIds"], message: "Укажите разные ID путей из профиля" })
}
})
async function analyzeCommunityProfile(profile: z.infer<typeof routeAiProfileSchema>, context: ActionContext) {
const ids = profileTarget(profile)
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Профиль выходит за область доступа")
const authorize = routeContextAccess(context)
await authorize()
const ranking = rankRoutingCandidates(profile, await readRoutingObservations(ids))
const routeContext = await readRouteCommunityContext(profile, { authorize, signal: context.signal })
for (const row of ranking) if (routeContext.before.candidateDiagnostics.find(diagnostic => diagnostic.candidateId === row.candidate.id)?.status !== "available") {
row.eligible = false; row.reasons.push("CANDIDATE_CONTEXT_UNAVAILABLE")
}
ranking.sort((a, b) => Number(b.eligible) - Number(a.eligible))
return { profile, ranking, ...routeContext.before, observedPath: false as const, returnPathVerified: false as const, observedAt: new Date().toISOString() }
}
async function assertCandidateEligible(profile: z.infer<typeof routeAiProfileSchema>, candidateId: string) {
const candidate = rankRoutingCandidates(profile, await readRoutingObservations(profileTarget(profile))).find(row => row.candidate.id === candidateId)
if (!candidate?.eligible) throw new McpError("MEASUREMENT_UNKNOWN", "Выбранный путь не удовлетворяет свежести измерений, ограничениям и SLA: " + (candidate?.reasons.join("; ") ?? "путь отсутствует"), 409)
@@ -65,21 +100,33 @@ async function filtersBefore(input: Record<string, unknown>, afterWrite = false)
export function registerRoutingActions(register: typeof add) {
registerRoutingMeasurement(register)
registerScopedCommunityService()
register("routing.measurements.read", "mm_routing_measurements_query", "История измерений со статусом проверки источника", "network", "mm:network:read", "read", "servers", z.object({ serverId: sid.optional() }).strict(), {
register("routing.measurements.read", "mm_routing_measurements_query", "История измерений со статусом проверки источника", "network", "mm:network:read", "read", "servers", routingHistorySchema, {
target: (_input, context) => ({ scope: "servers", serverIds: context.connection.policy.scope.serverIds }),
read: async (input, context) => ({ observations: await readRoutingObservations(context.connection.policy.scope.serverIds, input.serverId as number | undefined) }),
read: async (input, context) => queryRoutingObservations(context.connection.policy.scope.serverIds, routingHistorySchema.parse(input)),
})
register("routing.analyze", "mm_route_optimizer_analyze", "Анализ профиля Community", "network", "mm:network:read", "read", "servers", z.object({ profile: routeAiProfileSchema }).strict(), {
register("routing.analyze", "mm_route_optimizer_analyze", "Анализ профиля Community", "network", "mm:network:read", "read", "servers", routeAnalysisSchema, {
target: input => ({ scope: "servers", serverIds: profileTarget(routeAiProfileSchema.parse(input.profile)) }),
read: async (input, context) => {
const profile = routeAiProfileSchema.parse(input.profile), ids = profileTarget(profile)
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Профиль выходит за область доступа")
const ranking = rankRoutingCandidates(profile, await readRoutingObservations(ids))
const routeContext = await readRouteCommunityContext(profile, { authorize: routeContextAccess(context), signal: context.signal })
for (const row of ranking) if (routeContext.before.candidateDiagnostics.find(diagnostic => diagnostic.candidateId === row.candidate.id)?.status !== "available") {
row.eligible = false; row.reasons.push("CANDIDATE_CONTEXT_UNAVAILABLE")
}
return { profile, ranking, ...routeContext.before, observedPath: false, returnPathVerified: false, observedAt: new Date().toISOString() }
const { profile, offset, limit, eligibility } = routeAnalysisSchema.parse(input)
const { ranking, candidateDiagnostics, ...analysis } = await analyzeCommunityProfile(profile, context)
const filtered = eligibility === "all" ? ranking : ranking.filter(row => row.eligible === (eligibility === "eligible"))
const page = filtered.slice(offset, offset + limit)
const candidateIds = new Set(page.map(row => row.candidate.id))
return { ...analysis, ranking: page, candidateDiagnostics: candidateDiagnostics.filter(row => candidateIds.has(row.candidateId)),
total: filtered.length, offset, limit, nextOffset: offset + limit < filtered.length ? offset + limit : null }
},
})
register("routing.compare", "mm_route_optimizer_compare", "Сравнение выбранных путей Community", "network", "mm:network:read", "read", "servers", routeComparisonSchema, {
target: input => ({ scope: "servers", serverIds: profileTarget(routeAiProfileSchema.parse(input.profile)) }),
read: async (input, context) => {
const { profile, candidateIds } = routeComparisonSchema.parse(input)
const analysis = await analyzeCommunityProfile(profile, context)
const ranking = analysis.ranking.flatMap(({ candidate, ...row }, index) => candidateIds.includes(candidate.id)
? [{ candidateId: candidate.id, profileRank: index + 1, ...row }]
: [])
return { serverId: profile.serverId, family: profile.family, table: profile.table, community: profile.community,
ranking, bestEligibleCandidateId: ranking.find(row => row.eligible)?.candidateId ?? null,
observedPath: analysis.observedPath, returnPathVerified: analysis.returnPathVerified, observedAt: analysis.observedAt }
},
})
@@ -216,7 +263,7 @@ export function registerRoutingActions(register: typeof add) {
warnings: evidence.warnings, criteria: { maxLossPct: input.maxLossPct, pingWeight: input.pingWeight, ecmpPaths: input.ecmpPaths }, executionAvailable: false, proposalOnly: true, forwardingVerified: false, newApprovedPlanRequired: true }
},
})
register("routing.ospf.optimize", "mm_ospf_optimizer_plan", "Оптимизация стоимости OSPF без пересчёта при применении", "network", "mm:network:write", "write", "servers", ospfOptimizerSchema, { target: (_input, context) => ({ scope: "servers", serverIds: context.connection.policy.scope.serverIds }), prepare: buildOspfOptimizationEvidence, version: 9 })
register("routing.ospf.optimize", "mm_ospf_optimizer_plan", "Оптимизация стоимости OSPF без пересчёта при применении", "network", "mm:network:write", "write", "servers", ospfOptimizerSchema, { target: (_input, context) => ({ scope: "servers", serverIds: context.connection.policy.scope.serverIds }), prepare: buildOspfOptimizationEvidence, version: 10 })
}
async function buildOspfOptimizationEvidence(input: Record<string, unknown>, context: ActionContext, mode: "read" | "write" = "write"): Promise<ChangeEvidence & { before: { ranking: OspfRank[] } }> {
@@ -151,6 +151,46 @@ export async function acceptRouteCommunityContextMcp(actor: AuthUser) {
const legacyComment = policyComment
const withoutProbe = (({ controlProbe: _probe, ...candidate }) => candidate)(base.candidates[0])
const mainProfile = routeAiProfileSchema.parse({ ...base, table: "main", candidates: [withoutProbe, { ...withoutProbe, id: "b", gateway: "192.0.2.9", gatewayRef: { resource: "ipv4_route", id: "*r2" } }] })
const compareAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Community comparison", mode: "observer", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read"], scope: { serverIds: [sid], operationIds: ["routing.compare"], allowGlobal: false } }), actor)
const analyzeAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Community analysis only", mode: "observer", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read"], scope: { serverIds: [sid], operationIds: ["routing.analyze"], allowGlobal: false } }), actor)
try {
const comparePrincipal = await authenticateMcpToken(compareAccess.token)
const analyzePrincipal = await authenticateMcpToken(analyzeAccess.token)
const compare = (candidateIds: string[], profile = mainProfile) => invokeMcpTool(comparePrincipal, "mm_route_optimizer_compare", { profile, candidateIds })
const beforeDenied = requests
await assert.rejects(invokeMcpTool(analyzePrincipal, "mm_route_optimizer_compare", { profile: mainProfile, candidateIds: ["a", "b"] }))
await assert.rejects(compare(["a", "a"]))
await assert.rejects(compare(["a", "missing"]))
await assert.rejects(compare(["a", "b"], routeAiProfileSchema.parse({ ...mainProfile, candidates: [mainProfile.candidates[0], { ...mainProfile.candidates[1], exitNodeId: sid + 1 }] })))
assert.equal(requests, beforeDenied)
const beforeWrites = writes
const analyzed = await invokeMcpTool(analyzePrincipal, "mm_route_optimizer_analyze", { profile: mainProfile }) as { ranking: Array<{ candidate: { id: string }; eligible: boolean; score: number | null }> }
const result = await compare(["b", "a"]) as { ranking: Array<{ candidateId: string; profileRank: number; eligible: boolean; score: number | null }>; observedPath: boolean; returnPathVerified: boolean }
assert.deepEqual(result.ranking.map(({ candidateId, profileRank, eligible, score }) => ({ candidateId, profileRank, eligible, score })), analyzed.ranking.map(({ candidate, eligible, score }, index) => ({ candidateId: candidate.id, profileRank: index + 1, eligible, score })))
assert.equal(result.observedPath, false)
assert.equal(result.returnPathVerified, false)
const pagedProfile = routeAiProfileSchema.parse({ ...mainProfile, candidates: [...mainProfile.candidates, ...Array.from({ length: 20 }, (_, index) => ({ ...mainProfile.candidates[1], id: `missing-${String(index).padStart(2, "0")}`, gatewayRef: { resource: "ipv4_route", id: `*absent${index}` } }))] })
type AnalysisPage = { ranking: Array<{ candidate: { id: string }; eligible: boolean }>; candidateDiagnostics: Array<{ candidateId: string }>; total: number; nextOffset: number | null; currentCandidateId: string | null }
const page = (options = {}) => invokeMcpTool(analyzePrincipal, "mm_route_optimizer_analyze", { profile: pagedProfile, ...options }) as Promise<AnalysisPage>
const firstPage = await page(), secondPage = await page({ offset: 20 }), full = await page({ limit: 100 })
assert.equal(firstPage.total, 22)
assert.equal(firstPage.ranking.length, 20)
assert.equal(firstPage.candidateDiagnostics.length, 20)
assert.equal(firstPage.nextOffset, 20)
assert.equal(secondPage.nextOffset, null)
assert.deepEqual([...firstPage.ranking, ...secondPage.ranking], full.ranking)
assert.equal(secondPage.currentCandidateId, full.currentCandidateId)
const eligible = await page({ eligibility: "eligible" }), ineligible = await page({ eligibility: "ineligible", limit: 100 })
assert.deepEqual(eligible.ranking, full.ranking.filter(row => row.eligible))
assert.deepEqual(ineligible.ranking, full.ranking.filter(row => !row.eligible))
await assert.rejects(page({ limit: 101 }))
assert.equal(writes, beforeWrites)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_plans WHERE connection_id=$1", [compareAccess.connection.id])).rows[0].n, 0)
assert.deepEqual(privateMarkerPaths(result), [])
console.log("✓ Route AI comparison: explicit observer scope, invalid IDs and hidden dependencies rejected before RouterOS, analysis parity and no plan or write")
} finally {
await pool.query("DELETE FROM mcp_connections WHERE id=ANY($1::uuid[])", [[compareAccess.connection.id, analyzeAccess.connection.id]])
}
for (const iface of ["wan", "hop"]) {
const observationId = randomUUID()
await pool.query("INSERT INTO mcp_routing_observations(id,src_server_id,dst_server_id,src_interface,dst_interface,family,routing_table,ping_ms,loss_pct,dl_mbps,ul_mbps,ping_at,bandwidth_at,context_verified,source) VALUES($1,$2,$2,$3,'peer','ipv4','main',1,0,100,100,now(),now(),true,'fixture')", [observationId, sid, iface])
@@ -17,6 +17,7 @@ import { routeContextClients } from "./routing-community-context-native.js"
import { pathProofTag } from "./path-proof-purpose.js"
import { assertManagementTransport, managementTransportClient, type ManagementTransportCapability } from "./management-transport.js"
import { assertRoutingNetworkSharedContextRead, inspectRoutingNetworkSharedContextRead, readRoutingNetworkSharedResource, routingNetworkSharedContextPublicationFence, type RoutingNetworkSharedContextRead } from "./routing-network-native-session.js"
import { scopedTargetRuleMatches } from "./routing-community-scoped-model.js"
export function routeContextAccess(context: ActionContext) {
return async () => {
@@ -27,7 +28,7 @@ export function routeContextAccess(context: ActionContext) {
}
}
export type RouteContextOptions = { authorize?: () => Promise<void>; signal?: AbortSignal; expectedSources?: Array<{ serverId: number; proof: string }>; selectedCandidateId?: string; managementTransport?: ManagementTransportCapability }
export type OwnedRouteCommunityTarget = { nativeId: string; rawRow: NativeBgpRow; endpoint: Readonly<RoutingEndpoint>; desiredTargetProof: (ruleBody: string) => string }
export type OwnedRouteCommunityTarget = { nativeId: string; rawRow: NativeBgpRow; endpoint: Readonly<RoutingEndpoint>; desiredTargetProof: (ruleBody: string) => string; matchesRuleProof: (ruleBody: string, proof: unknown) => boolean }
/** Fixed endpoint clients and private proofs are retained in closures. Public
* results contain no credentials, native rules, arbitrary comments or SQL rows. */
@@ -234,13 +235,16 @@ async function readContext(profile: RouteAiProfile, options: RouteContextOptions
// untouched dependencies after its deliberate, exact body replacement.
const targetRuleProof = targetRule ? sign("target-rule:" + fingerprint(stableBgpPolicyRow(targetRule))) : null
const immutableTarget = targetRule ? structuredClone(targetRule) : null
const ownedTarget: OwnedRouteCommunityTarget | undefined = immutableTarget ? {
nativeId: immutableTarget[".id"], rawRow: structuredClone(immutableTarget), endpoint: Object.freeze({ ...endpoints.find(endpoint => endpoint.id === sourceId)! }),
desiredTargetProof(ruleBody) {
const ownedTarget: OwnedRouteCommunityTarget | undefined = immutableTarget ? (() => {
const desiredTargetProof = (ruleBody: string) => {
if (typeof ruleBody !== "string" || Buffer.byteLength(ruleBody, "utf8") > 65536) throw new McpError("LIMIT_EXCEEDED", "Тело scoped Community фильтра превышает лимит", 409)
return sign("target-rule:" + fingerprint(stableBgpPolicyRow({ ...immutableTarget, rule: ruleBody })))
},
} : undefined
}
return {
nativeId: immutableTarget[".id"], rawRow: structuredClone(immutableTarget), endpoint: Object.freeze({ ...endpoints.find(endpoint => endpoint.id === sourceId)! }),
desiredTargetProof, matchesRuleProof: (ruleBody: string, proof: unknown) => scopedTargetRuleMatches({ rawRow: immutableTarget, desiredTargetProof }, ruleBody, proof),
}
})() : undefined
const dependencies = rawProof.map(value => {
const snapshot = value as { path?: string; rows?: Record<string, unknown>[] }
return snapshot.path === "/routing/filter/rule" && snapshot.rows && targetRule ? { ...snapshot, rows: snapshot.rows.map(row => row[".id"] === targetRule[".id"] ? { ...row, rule: "[separately-sealed-owned-body]" } : row) } : value
@@ -284,5 +288,5 @@ export async function readRouteCommunityContextWithSharedNativeSession(sharedCon
// The acquisition ends when this function returns. Later publication fences
// must use the owner rather than retain a completed transport-read permit.
return { before: result.before, fence: routingNetworkSharedContextPublicationFence(sharedContext),
ownedTarget: result.ownedTarget ? { nativeId: result.ownedTarget.nativeId, rawRow: structuredClone(result.ownedTarget.rawRow) } : undefined }
ownedTarget: result.ownedTarget ? { nativeId: result.ownedTarget.nativeId, rawRow: structuredClone(result.ownedTarget.rawRow), matchesRuleProof: result.ownedTarget.matchesRuleProof } : undefined }
}
@@ -1,4 +1,5 @@
import type { ChangeEvidence } from "./action-types.js"
import { isRoutingPolicySnapshot } from "./resources.js"
const record = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}
const rules = (value: unknown) => Array.isArray(value) ? value.map(item => {
@@ -33,8 +34,13 @@ export function publicRoutingCommunityChanges(evidence: ChangeEvidence) {
/** Service recovery has the same private native bodies as its forward plan. */
export function publicMcpChangeEvidence(operationId: string, evidence: ChangeEvidence) {
const scopedRecovery = operationId === "operations.recover" && evidence.steps.length === 1 && evidence.steps[0].kind === "service" && evidence.steps[0].service === "routing.community.scoped.recover"
if (operationId === "routing.profiles.save") return {...evidence,steps:evidence.steps.map(step=>{if(step.kind!=="service")return step;const {baseline:_privateBaseline,...input}=step.input;return {...step,input}})}
return operationId === "routing.community.switch" || scopedRecovery ? publicRoutingCommunityChanges(evidence) : evidence
if (operationId === "routing.profiles.save" || operationId === "routing.controller.update") return {...evidence,steps:evidence.steps.map(step=>{if(step.kind!=="service")return step;const {baseline:_privateBaseline,...input}=step.input;return {...step,input}})}
if (operationId === "routing.community.switch" || scopedRecovery) return publicRoutingCommunityChanges(evidence)
if (evidence.healthChecks?.some(check => check.nextHopInterface)) evidence = { ...evidence, healthChecks: evidence.healthChecks.map(({ nextHopInterface: _privateInterface, ...check }) => check) }
if (!evidence.snapshots.some(snapshot => isRoutingPolicySnapshot(snapshot.resource))) return evidence
return { ...evidence, snapshots: evidence.snapshots.map(snapshot => isRoutingPolicySnapshot(snapshot.resource)
? { serverId: snapshot.serverId, resource: snapshot.resource, rowCount: snapshot.rows.length }
: snapshot) }
}
/** Public controller DTO is explicit. Private baseline/native proofs, seal
File diff suppressed because it is too large. Load diff
@@ -1,7 +1,7 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { compileManagedFilterRules } from "../../services/filter-rules-write-model.js"
import { compileScopedCommunitySwitch, scopedConfigurationMatches } from "./routing-community-scoped-model.js"
import { compileScopedCommunitySwitch, scopedConfigurationMatches, scopedTargetRuleMatches } from "./routing-community-scoped-model.js"
const rules = [{ community: "65000:10", action: "route" as const, gateway: "192.0.2.1", outIface: "", description: "" },
{ community: "65000:20", action: "route" as const, gateway: "192.0.2.1", outIface: "", description: "" },
@@ -22,6 +22,27 @@ test("full roundtrip rejects hidden script, unknown branches, comments, invalid
assert.throws(() => compileScopedCommunitySwitch(body, "65000:30", "192.0.2.9", "", "ipv4"))
assert.throws(() => compileScopedCommunitySwitch(body, "65000:10", "192.0.2.9", 'bad";accept;', "ipv4"))
})
test("native optional quotes preserve exact managed body and existing proof format", () => {
const quoted = compileManagedFilterRules([{ ...rules[0], outIface: "marker-gre-a" }])
const bare = quoted.replace('"marker-gre-a"', "marker-gre-a")
const change = (body: string) => compileScopedCommunitySwitch(body, "65000:10", "192.0.2.9", "marker-gre-b", "ipv4")
assert.deepEqual(change(bare), change(quoted))
assert.match(change(bare).compiledBody, / set gw-interface "marker-gre-b";/)
const proof = (body: string) => JSON.stringify({ id: "*1", body })
const target = (rule: string) => ({ rawRow: { rule }, desiredTargetProof: proof })
assert.equal(scopedTargetRuleMatches(target(bare), quoted, proof(quoted)), true)
assert.equal(scopedTargetRuleMatches(target(quoted), bare, proof(bare)), true)
assert.equal(scopedTargetRuleMatches(target(bare), quoted, proof(bare)), false)
assert.equal(scopedTargetRuleMatches(target(bare), quoted, JSON.stringify({ id: "*2", body: quoted })), false)
for (const altered of [bare.replace("marker-gre-a", "marker-gre-x"), bare.replace("192.0.2.1", "192.0.2.2"),
bare.replace(" set gw-interface", " set gw-interface"), bare.replace("gw-interface", "out-interface"), bare + "\naccept;"]) {
assert.equal(scopedTargetRuleMatches(target(altered), quoted, proof(quoted)), false)
}
for (const name of ["marker:gre/a", "100", "marker gre a"]) {
const unusual = compileManagedFilterRules([{ ...rules[0], outIface: name }])
assert.equal(scopedTargetRuleMatches(target(unusual.replace('"' + name + '"', name)), unusual, proof(unusual)), false)
}
})
test("scoped configuration comparison needs dependency, native metadata and candidate identity conjunction", () => {
const input = { dependencyProof: "dependencies", originalTargetProof: "original", desiredTargetProof: "desired", candidateId: "b" }
const state = { dependencyProof: "dependencies", targetRuleProof: "desired", currentCandidateId: "b" }
@@ -1,5 +1,6 @@
import { isIP } from "node:net"
import { compileManagedFilterRules, filterRuleBranches, filterRuleToken } from "../../services/filter-rules-write-model.js"
import { managedRoutingFilterBodiesMatch } from "../../services/routing-filter-body.js"
type Rule = { community: string; action: "route" | "blackhole"; gateway: string; outIface: string; description: string }
/** Deliberately accepts only the complete compiler output, never partial script parsing. */
@@ -11,7 +12,7 @@ export function compileScopedCommunitySwitch(body: string, community: string, ga
const blackhole = /^\s*set blackhole yes;\s*accept;\s*$/.test(branch.body)
for (const match of branch.condition.matchAll(/bgp-communities includes (\d+:\d+)/g)) rules.push({ community: match[1], action: blackhole ? "blackhole" : "route", gateway: blackhole ? "" : filterRuleToken(branch.body, "gw"), outIface: blackhole ? "" : filterRuleToken(branch.body, "interface"), description: "" })
}
if (!rules.length || compileManagedFilterRules(rules) !== body || new Set(rules.map(rule => rule.community)).size !== rules.length) throw new Error("Full compiler roundtrip required")
if (!rules.length || !managedRoutingFilterBodiesMatch(body, compileManagedFilterRules(rules)) || new Set(rules.map(rule => rule.community)).size !== rules.length) throw new Error("Full compiler roundtrip required")
const selected = rules.filter(rule => rule.community === community)
if (selected.length !== 1 || selected[0].action !== "route") throw new Error("Unique routed community required")
const before = { ...selected[0] }
@@ -20,6 +21,11 @@ export function compileScopedCommunitySwitch(body: string, community: string, ga
return { compiledBody, before, proposed: desired.find(rule => rule.community === community)! }
}
export function scopedTargetRuleMatches(target: { rawRow: Record<string, unknown>; desiredTargetProof: (body: string) => string }, expectedBody: string, expectedProof: unknown) {
return typeof expectedProof === "string" && managedRoutingFilterBodiesMatch(target.rawRow.rule, expectedBody) &&
target.desiredTargetProof(expectedBody) === expectedProof
}
export function scopedConfigurationMatches(input: { dependencyProof: unknown; originalTargetProof: unknown; desiredTargetProof: unknown; candidateId: unknown }, observed: { dependencyProof: unknown; targetRuleProof: unknown; currentCandidateId: unknown }) {
const dependencies = input.dependencyProof === observed.dependencyProof
return { configurationMatchesOriginal: dependencies && input.originalTargetProof === observed.targetRuleProof,
@@ -1,3 +1,4 @@
import { withPartitionWrite } from "../../services/partition-write-transaction.js"
import { randomUUID } from "node:crypto"
import { z } from "zod"
import type { PoolClient } from "pg"
@@ -156,7 +157,7 @@ async function observe(value: Session, phase: "before" | "after", requireSla = t
const expectedBody = phase === "before" ? packet.desiredRule : packet.originalRule
const expectedProof = phase === "before" ? packet.desiredTargetProof : packet.originalTargetProof
const expectedCandidate = phase === "before" ? packet.desiredCandidateId : packet.originalCandidateId
if (context.before.dependencyProof !== packet.dependencyProof || context.before.targetRuleProof !== expectedProof || context.before.currentCandidateId !== expectedCandidate || context.ownedTarget.rawRow.rule !== expectedBody) throw fail("Конфигурация или зависимости обратного действия изменены")
if (context.before.dependencyProof !== packet.dependencyProof || context.before.currentCandidateId !== expectedCandidate || !context.ownedTarget.matchesRuleProof(expectedBody, expectedProof)) throw fail("Конфигурация или зависимости обратного действия изменены")
await context.fence(); await value.authorize()
return context
}
@@ -420,8 +421,10 @@ export function registerScopedRecoveryService() {
reconcile: async (input, authorize) => {
const value = await session(input, false, authorize, true), context = await readRouteCommunityContext(value.source.profile, value.options)
const packet = value.source.configuration.packet, dependencies = context.before.dependencyProof === packet.dependencyProof
const configurationMatchesDesired = dependencies && context.before.targetRuleProof === packet.originalTargetProof && context.before.currentCandidateId === packet.originalCandidateId
const configurationMatchesOriginal = dependencies && context.before.targetRuleProof === packet.desiredTargetProof && context.before.currentCandidateId === packet.desiredCandidateId
const target = context.ownedTarget
const sameTarget = target?.nativeId === packet.nativeId
const configurationMatchesDesired = dependencies && sameTarget && context.before.currentCandidateId === packet.originalCandidateId && target.matchesRuleProof(packet.originalRule, packet.originalTargetProof)
const configurationMatchesOriginal = dependencies && sameTarget && context.before.currentCandidateId === packet.desiredCandidateId && target.matchesRuleProof(packet.desiredRule, packet.desiredTargetProof)
const record = await verifiedRecord(input, value)
const configurationCheckpointAvailable = await configurationRecord(input, value)
const expected = record ? await recoveryResult(input, record.verifiedProof) : null
@@ -437,5 +440,17 @@ export function registerScopedRecoveryService() {
/** Restart maintenance is read-only with respect to the network. */
export async function recoverScopedRecoveryIntents() {
await pool.query("UPDATE mcp_scoped_recovery_intents r SET state='unknown',finished_at=now() WHERE r.state='intent' AND (r.deadline<=now() OR r.recovery_operation_id IS NOT NULL AND EXISTS (SELECT 1 FROM mcp_operations o WHERE o.id=r.recovery_operation_id AND o.state NOT IN ('pending','running')))")
await withPartitionWrite(({ client }) => client.query(`WITH interrupted AS (
SELECT intent.source_operation_id,intent.source_step_index
FROM mcp_scoped_recovery_intents intent
WHERE intent.state='intent' AND (intent.deadline<=now() OR EXISTS (
SELECT 1 FROM mcp_operations operation
WHERE operation.id=intent.recovery_operation_id AND operation.state NOT IN ('pending','running')
))
ORDER BY intent.deadline,intent.source_operation_id,intent.source_step_index
LIMIT 1000 FOR UPDATE SKIP LOCKED
)
UPDATE mcp_scoped_recovery_intents intent SET state='unknown',finished_at=now()
FROM interrupted WHERE intent.source_operation_id=interrupted.source_operation_id
AND intent.source_step_index=interrupted.source_step_index`))
}
@@ -1,5 +1,18 @@
import type { PoolClient } from "pg"
import { COMMUNITY_PATH_MAX_AGE_MS } from "./path-proof-purpose.js"
import { fingerprint, McpError, privateSecretFingerprint } from "./security.js"
export async function readScopedPathCheckpoint(db: Pick<PoolClient, "query">, id: string, connectionId: string, phase: "admission" | "configuration" = "admission") {
const { rows: [row] } = await db.query("SELECT id,connection_id,profile_hash,candidate_id,status,created_at,evidence FROM mcp_path_checks WHERE id=$1 AND connection_id=$2", [id, connectionId])
const now = Date.now(), createdAt = row?.created_at.getTime(), observedAt = Date.parse(row?.evidence?.observedAt)
const validUntil = Math.min(createdAt, observedAt) + COMMUNITY_PATH_MAX_AGE_MS
if (!row || row.status !== "passed" || createdAt > now || phase === "admission" &&
(!Number.isFinite(validUntil) || observedAt > now || validUntil <= now)) {
throw new McpError("MEASUREMENT_UNKNOWN", "Контрольное подтверждение пути устарело", 409)
}
return { proof: await privateSecretFingerprint(fingerprint(row), "route-ai-scoped-path-checkpoint-v1"), validUntil }
}
/** One request domain for forward execution and trusted recovery resolution. */
export async function assertScopedCommunityRequest(input: Record<string, unknown>) {
const { requestProof: _proof, ...request } = input
@@ -1,7 +1,7 @@
import { createRoutingNetworkSessionForCurrentExecution } from "./routing-network-execution-session.js"
import { readRoutingNetworkScopedCommunityContext } from "./routing-network-scoped-context.js"
import { resolveRoutingNetworkForwardRoundConfigurationContext, closeRoutingNetworkForwardRoundConfigurationContext } from "./routing-network-configuration-bridge.js"
import { originalEffectSelectionSchema, unknownOriginalEffects, originalEffectBaselineMatches, inspectOriginalEffectRounds, type OriginalEffectReport } from "./routing-original-effects.js"
import { originalEffectReportSchema, originalEffectSelectionSchema, unknownOriginalEffects, originalEffectBaselineMatches, inspectOriginalEffectRounds, type OriginalEffectReport } from "./routing-original-effects.js"
import { randomUUID } from "node:crypto"
import { z } from "zod"
import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing"
@@ -9,12 +9,13 @@ import { pool } from "../../db/index.js"
import { hasPermission } from "../../lib/permissions.js"
import { assertVerifiedConfigurationAccess, executeGuardedConfigurationWrite } from "../../services/config-mutation-policy.js"
import { withApplicationMutationLock, withConfigMutationLock } from "../../services/config-mutation-lock.js"
import { managedRoutingFilterBodiesMatch } from "../../services/routing-filter-body.js"
import { effectiveMcpConnection, audit } from "./store.js"
import { accessGeneration, decryptSecret, encryptSecret, fingerprint, McpError, privateSecretFingerprint, redact } from "./security.js"
import { saveScopedConfigurationCheckpoint, loadScopedConfigurationCheckpoint, saveScopedNetworkCheckpoint, loadScopedNetworkCheckpoint } from "./routing-community-scoped-checkpoint.js"
import { assertScopedCommunityRequest as sealedInput } from "./routing-community-scoped-request.js"
import { assertScopedCommunityRequest as sealedInput, readScopedPathCheckpoint } from "./routing-community-scoped-request.js"
import { prepareManagementTransportBinding } from "./management-transport.js"
import { readRouteCommunityContext } from "./routing-community-context.js"
import { readRouteCommunityContext, type OwnedRouteCommunityTarget } from "./routing-community-context.js"
import { candidateMatchesGateway } from "./routing-community-context-model.js"
import { rankRoutingCandidates } from "./routing-model.js"
import { readRoutingObservations } from "./routing-observations.js"
@@ -37,12 +38,6 @@ const effectReadFailure = (stage: "original-effect-rounds-unavailable" | "effect
export const scopedProfileIds = (profile: z.infer<typeof routeAiProfileSchema>) => [...new Set([profile.serverId, ...profile.candidates.flatMap(candidate => [candidate.jumpHostId, candidate.exitNodeId])])].sort((a, b) => a - b)
const publicRule = (rule: { community: string; gateway: string; outIface: string }) => ({ community: rule.community, action: "route", gateway: rule.gateway, gatewayTunnelId: rule.outIface })
type ObservationPhase = "admission" | "configuration"
async function pathCheckpoint(id: string, connectionId: string, phase: ObservationPhase = "admission") {
const row = (await pool.query("SELECT id,connection_id,profile_hash,candidate_id,status,created_at,evidence FROM mcp_path_checks WHERE id=$1 AND connection_id=$2", [id, connectionId])).rows[0]
if (!row || row.status !== "passed" || row.created_at.getTime() > Date.now() || phase === "admission" && Date.now() - row.created_at.getTime() > 90000) throw fail("Контрольное подтверждение пути устарело", "MEASUREMENT_UNKNOWN")
return privateSecretFingerprint(fingerprint(row), "route-ai-scoped-path-checkpoint-v1")
}
async function access(input: Record<string, unknown>, signal?: AbortSignal, verified = false, phase: ObservationPhase = "admission") {
if (signal?.aborted) throw fail("Операция отменена", "READ_CANCELLED")
if (verified) await assertVerifiedConfigurationAccess()
@@ -58,7 +53,7 @@ async function access(input: Record<string, unknown>, signal?: AbortSignal, veri
const ranked = rankRoutingCandidates(profile, await readRoutingObservations(ids))
if (!ranked.find(row => row.candidate.id === input.candidateId)?.eligible) throw fail("Нет свежего SLA выбранного пути", "MEASUREMENT_UNKNOWN")
}
if (input.pathCheckId !== undefined && await pathCheckpoint(String(input.pathCheckId), principal.connectionId, phase) !== input.pathCheckpointProof) throw fail("Подтверждение пути изменилось", "MEASUREMENT_UNKNOWN")
if (input.pathCheckId !== undefined && (await readScopedPathCheckpoint(pool, String(input.pathCheckId), principal.connectionId, phase)).proof !== input.pathCheckpointProof) throw fail("Подтверждение пути изменилось", "MEASUREMENT_UNKNOWN")
return { principal, current, profile, ids }
}
@@ -90,6 +85,13 @@ async function receipt(input: Record<string, unknown>, action: string) {
await audit(pool, String(input.ownerId), action, principalSchema.parse(input.principal).connectionId, { receiptId: input.receiptId, requestSeal, revision: input.proofRevision })
}
const matches = (input: Record<string, unknown>, context: { before: Parameters<typeof scopedConfigurationMatches>[1] }) => scopedConfigurationMatches({ dependencyProof: input.dependencyProof, originalTargetProof: input.originalTargetProof, desiredTargetProof: input.desiredTargetProof, candidateId: input.candidateId }, context.before)
async function matchesDesired(input: Record<string, unknown>, context: { before: Parameters<typeof scopedConfigurationMatches>[1]; ownedTarget: Pick<OwnedRouteCommunityTarget, "nativeId" | "matchesRuleProof"> | undefined }) {
if (matches(input, context).configurationMatchesDesired) return true
const target = context.ownedTarget
return !!target && target.nativeId === input.nativeId && context.before.dependencyProof === input.dependencyProof &&
context.before.currentCandidateId === input.candidateId &&
target.matchesRuleProof(await decryptSecret(String(input.desiredRuleCiphertext)), input.desiredTargetProof)
}
async function original(input: Record<string, unknown>, verified: boolean) {
await sealedInput(input)
const value = await observe(input, undefined, verified)
@@ -117,7 +119,8 @@ export async function prepareScopedCommunitySwitch(input: Record<string, unknown
await access(request, action.signal)
}
const pathCheckId = await requireRecentPath(profile, String(input.candidateId), action.connection.id, { authorize: async () => { await access(request, action.signal) }, signal: action.signal })
const withPath = { ...request, pathCheckId, pathCheckpointProof: await pathCheckpoint(pathCheckId, action.connection.id) }
const checkpoint = await readScopedPathCheckpoint(pool, pathCheckId, action.connection.id)
const withPath = { ...request, pathCheckId, pathCheckpointProof: checkpoint.proof }
const value = await observe(withPath, action.signal), owned = value.context.ownedTarget!
if (!value.candidate.controlProbe) throw fail("Scoped переключение требует свежий control probe", "MEASUREMENT_UNKNOWN")
await requireRecentPath(value.profile, value.candidate.id, action.connection.id, { authorize: value.authorize, signal: action.signal })
@@ -130,7 +133,7 @@ export async function prepareScopedCommunitySwitch(input: Record<string, unknown
const outIface = value.candidate.gatewayRef.resource === "gre" ? String(target[0].name) : ""
if (!candidateMatchesGateway(value.profile, value.candidate, { gateway: value.candidate.gateway, gatewayInterface: outIface }, target[0])) throw fail("Gateway кандидата не соответствует exact native identity")
const compiled = compileScopedCommunitySwitch(String(owned.rawRow.rule), value.profile.community, value.candidate.gateway, outIface, value.profile.family)
if (compiled.compiledBody === owned.rawRow.rule) throw fail("Путь уже выбран")
if (managedRoutingFilterBodiesMatch(owned.rawRow.rule, compiled.compiledBody)) throw fail("Путь уже выбран")
let managementBindingCiphertext: string | null = null
const hasChannel = (await pool.query("SELECT id FROM mcp_management_channels WHERE server_id=$1 AND revoked_at IS NULL AND expires_at>now()", [profile.serverId])).rows.length > 0
if (hasChannel) {
@@ -149,9 +152,12 @@ export async function prepareScopedCommunitySwitch(input: Record<string, unknown
sealed.requestProof = await privateSecretFingerprint(fingerprint(sealed), `route-ai-scoped-request-v${sealed.proofRevision}`)
await value.context.fence()
await access(sealed, action.signal)
const authorityValidUntil = persistent ? routingProfileAuthorityBindingSchema.parse(sealed.persistentProfileAuthority).packet.validUntil : undefined
const validUntil = authorityValidUntil && !profile.autoRecovery
? new Date(Math.min(Date.parse(authorityValidUntil), checkpoint.validUntil)).toISOString() : authorityValidUntil
return { target: { scope: "servers", serverIds: scopedProfileIds(value.profile) }, objects: [{ kind: "routing_filter", serverId: value.profile.serverId, id: owned.nativeId }, ...scopedProfileIds(value.profile).map(serverId => ({ kind: "server" as const, serverId, id: String(serverId) }))], snapshots: [], before: sealed.beforeRules,
steps: [{ kind: "service", service: "routing.community.scoped", input: sealed }], requiresHuman: true, mutationCost: 2, verificationScope: "configuration",
...(persistent ? { validUntil: routingProfileAuthorityBindingSchema.parse(sealed.persistentProfileAuthority).packet.validUntil } : {}),
...(validUntil ? { validUntil } : {}),
warnings: ["Одна запись owned фильтра; SQL метаданные фильтров сохраняются.", "SLA и свежая preflight-проба обязательны до отправки PATCH. После записи подтверждение конфигурации сохраняется независимо от новых SLA-измерений; контрольная проба и её результат проверяются отдельно.", "Подтверждается конфигурация и control probe; сквозной и обратный трафик требуют отдельной приёмки.", managementBindingCiphertext ? "Заранее закреплён независимый транспорт для отдельного обратного плана; автоматический inverse не включён." : "Независимый transport не закреплён; отдельный обратный план потребует доступного primary.", "Неизвестный результат не повторяется; сверка только читает состояние."] }
}
@@ -166,7 +172,9 @@ export function registerScopedCommunityService() {
}
await original(input, true)
if (await receiptExists(input, "routing.scoped.attempt")) throw fail("Повтор PATCH запрещён", "MUTATION_OUTCOME_UNKNOWN")
await receipt(input, "routing.scoped.attempt")
const requiresEffectBaseline = input.proofRevision === 3 && !routeAiProfileSchema.parse(input.profile).autoRecovery
let attemptRecorded = !requiresEffectBaseline
if (attemptRecorded) await receipt(input, "routing.scoped.attempt")
const before = await original(input, true), owned = before.context.ownedTarget!
let effectSession: Awaited<ReturnType<typeof createRoutingNetworkSessionForCurrentExecution>> | undefined
let effectBaseline: Parameters<typeof originalEffectBaselineMatches>[1] | undefined
@@ -174,8 +182,8 @@ export function registerScopedCommunityService() {
const effectSelection = originalEffectSelectionSchema.safeParse(input.originalEffectSelection)
if (effectSelection.success) originalEffects = unknownOriginalEffects(effectSelection.data.status === "unknown" ? effectSelection.data.reason : "effect-acquisition-unavailable")
try {
// Optional original-effect vector is never an extra write/recovery grant.
// Old manual configuration+probe acceptance remains authoritative.
// The persistent controller needs a baseline it can advance after the
// write. Manual plans still treat the effect vector as observational.
if (context?.phase === "forward" && (input.proofRevision === 3 || input.proofRevision === 2 && input.manualEffectPolicy === "observational-v1") && effectSelection.success && effectSelection.data.status === "prepared") {
try {
const selected = effectSelection.data
@@ -188,13 +196,18 @@ export function registerScopedCommunityService() {
}
} catch { effectSession?.close(); effectSession = undefined }
}
if (requiresEffectBaseline) {
if (!effectSession) throw fail("Исходное состояние изменяемого префикса не подтверждено", "MEASUREMENT_UNKNOWN")
await receipt(input, "routing.scoped.attempt")
attemptRecorded = true
}
await executeGuardedConfigurationWrite(before.profile.serverId, "PATCH", "/routing/filter/rule/" + owned.nativeId, async () => {
// This runs AFTER the durable configuration.intent, immediately before transport.
const fresh = await original(input, true); await before.context.fence()
await patchScopedCommunity(owned.endpoint, owned.nativeId, fresh.compiledBody)
})
const after = await observe(input, undefined, true, undefined, "configuration")
if (!matches(input, after.context).configurationMatchesDesired) throw fail("Read-back не подтверждён")
if (!await matchesDesired(input, after.context)) throw fail("Read-back не подтверждён")
await after.context.fence()
// This commit precedes the first post-change network probe. A failed
// probe cannot erase the exact, acknowledged configuration read-back.
@@ -208,8 +221,7 @@ export function registerScopedCommunityService() {
roundContext = await resolveRoutingNetworkForwardRoundConfigurationContext(effectSession.sharedContext, action)
const first = await effectSession.readConfigurationBoundForwardSnapshot(roundContext)
const second = await effectSession.readConfigurationBoundForwardSnapshot(roundContext)
const observed = await inspectOriginalEffectRounds(effectSelection.data, [first, second], effectBaseline), budget = effectSession.readBudget()
originalEffects = { ...observed, budget: { deadline: budget.deadline, calls: budget.calls, bytes: budget.bytes, rows: budget.rows } }
originalEffects = await inspectOriginalEffectRounds(effectSelection.data, [first, second], effectBaseline)
} catch (error) { originalEffects = effectReadFailure("original-effect-rounds-unavailable", error) }
finally { if (roundContext) closeRoutingNetworkForwardRoundConfigurationContext(roundContext) }
}
@@ -219,10 +231,8 @@ export function registerScopedCommunityService() {
if (effectSession && !effectSession.readBudget().closed) {
try {
const published = await readRoutingNetworkScopedCommunityContext(effectSession.sharedContext)
if (!matches(input, published).configurationMatchesDesired) throw fail("Состояние изменилось после effect-check")
if (!await matchesDesired(input, published)) throw fail("Состояние изменилось после effect-check")
await published.fence()
const budget = effectSession.readBudget()
originalEffects = { ...originalEffects!, budget: { deadline: budget.deadline, calls: budget.calls, bytes: budget.bytes, rows: budget.rows } }
sharedPublication = true
} catch (error) { originalEffects = effectReadFailure("effect-publication-revalidation-unavailable", error) }
}
@@ -231,26 +241,31 @@ export function registerScopedCommunityService() {
// never duplicated or counted as a completed effect-owner read.
// Closed/exhausted effect owners cannot waive this fresh guard.
const final = await observe(input, undefined, true, undefined, "configuration")
if (!matches(input, final.context).configurationMatchesDesired) throw fail("Состояние изменилось после probe")
if (!await matchesDesired(input, final.context)) throw fail("Состояние изменилось после probe")
await final.context.fence()
}
if (effectSession && originalEffects) {
const budget = effectSession.readBudget()
originalEffects = { ...originalEffects, budget: { deadline: budget.deadline, calls: budget.calls, bytes: budget.bytes, rows: budget.rows } }
const { deadline, calls, bytes, rows } = effectSession.readBudget()
const budget = originalEffectReportSchema.shape.budget.safeParse({ deadline, calls, bytes, rows })
if (budget.success) originalEffects = { ...originalEffects, budget: budget.data }
}
await saveScopedNetworkCheckpoint(input, await sealedInput(input), { ...check, status: check.status }, originalEffects)
return verifiedResult(input)
} catch { throw fail("Результат переключения не подтверждён; повтор запрещён, требуется сверка", "MUTATION_OUTCOME_UNKNOWN") }
} catch (error) {
if (!attemptRecorded) throw error
throw fail("Результат переключения не подтверждён; повтор запрещён, требуется сверка", "MUTATION_OUTCOME_UNKNOWN")
}
finally { effectSession?.close() }
})),
verify: async (input, result) => {
const expected = await verifiedResult(input)
if (!expected.configurationCheckpointAvailable || !expected.controlProbeVerified || fingerprint(result) !== fingerprint(expected)) return false
return matches(input, (await observe(input, undefined, false, undefined, "configuration")).context).configurationMatchesDesired
return matchesDesired(input, (await observe(input, undefined, false, undefined, "configuration")).context)
},
reconcile: async (input, authorize) => {
await sealedInput(input); await authorize()
const observed = await observe(input, undefined, false, authorize, "configuration"), config = matches(input, observed.context)
const observed = await observe(input, undefined, false, authorize, "configuration")
const config = { ...matches(input, observed.context), configurationMatchesDesired: await matchesDesired(input, observed.context) }
await observed.context.fence(); await authorize()
const expected = await verifiedResult(input)
const checkpoints = (await pool.query("SELECT s.result FROM mcp_operation_steps s JOIN mcp_operations o ON o.id=s.operation_id WHERE o.connection_id=$1 AND s.state='verified' AND s.intended_state=$2::jsonb AND s.result=$3::jsonb LIMIT 2", [principalSchema.parse(input.principal).connectionId, JSON.stringify(redact({ kind: "service", service: "routing.community.scoped", input })), JSON.stringify(expected)])).rows
@@ -12,7 +12,7 @@ async function fixture() {
const input: Record<string, unknown> = { profile: { autoRecovery: false }, persistentProfileAuthority: authority, nativeId: "*1", originalCandidateId: "a", originalTargetProof: "target-a", dependencyProof: "dep", contextSources: enrolled.sourceProofs }
const prepared: Record<string, unknown> = { ...input, desiredCandidateId: "b" }
const packet: Record<string, unknown> = { nativeId: "*1", originalCandidateId: "a", desiredCandidateId: "b", originalTargetProof: "target-a", desiredTargetProof: "target-b", dependencyProof: "dep", contextSources: enrolled.sourceProofs }
const source = { row: { id: "source-op" }, input, configuration: { checkpointProof: "configuration-proof", packet }, sourceProof: "source-proof" }
const source = { row: { id: "source-op", source_plan_id: "source-plan" }, input, configuration: { checkpointProof: "configuration-proof", packet }, sourceProof: "source-proof" }
const row = { id: "profile", version: 1, connection_id: "connection", grant_expires_at: new Date(Date.now() + 600000), config: {}, state: { baseline: { proof: "inert-authenticated-baseline" } } }
const metadata = { operationName: "routing.community.switch", operationVersion: 7, serviceName: "routing.community.scoped", phase: "forward", stepIndex: 0, ownerId: "owner", serviceInput: input, principal: {} }
let nativeWrites = 0
@@ -55,10 +55,35 @@ for (const [name, key, bad] of drifts) test(`scoped ${name} drift is refused bef
test("main fixed loaded original identity is admitted", async () => { const value = await fixture(); value.metadata.serviceName = "routing.community"; await value.run() })
for (const [name, key, bad] of drifts) test(`main decrypted ${name} drift is refused before native`, async () => { const value = await fixture(); value.metadata.serviceName = "routing.community"; value.prepared[key] = bad; await assert.rejects(value.run); assert.equal(value.writes(), 0) })
test("inverse with enrolled A awaiting its own B transition is admitted", async () => { const value = await fixture(); value.recovery(); await value.run() })
test("inverse from this exact source advanced B baseline is admitted", async () => { const value = await fixture(); value.recovery(); Object.assign(value.enrolled, { candidateId: "b", targetProof: "target-b", transition: { operationId: "source-op", checkpointProof: "configuration-proof" } }); await value.run() })
test("inverse with unrelated B source transition is refused", async () => { const value = await fixture(); value.recovery(); Object.assign(value.enrolled, { candidateId: "b", targetProof: "target-b", transition: { operationId: "different-source", checkpointProof: "configuration-proof" } }); await assert.rejects(value.run); assert.equal(value.writes(), 0) })
const sourceTransition = { operationId: "source-op", planId: "source-plan", checkpointProof: "configuration-proof" }
test("inverse from this exact source advanced B baseline is admitted", async () => { const value = await fixture(); value.recovery(); Object.assign(value.enrolled, { candidateId: "b", targetProof: "target-b", transition: sourceTransition }); await value.run() })
test("inverse from this source can bind a separately authenticated B readback seal", async () => {
const value = await fixture()
value.recovery()
Object.assign(value.enrolled, { candidateId: "b", targetProof: "observed-b", transition: { ...sourceTransition, sourceDesiredTargetProof: "target-b" } })
await value.run()
assert.equal(value.writes(), 1)
})
for (const [key, wrong] of Object.entries({ operationId: "other-source", planId: "other-plan", checkpointProof: "other-checkpoint", sourceDesiredTargetProof: "other-desired" })) {
test(`inverse from observed B refuses mismatched ${key}`, async () => {
const value = await fixture()
value.recovery()
Object.assign(value.enrolled, { candidateId: "b", targetProof: "observed-b", transition: { ...sourceTransition, sourceDesiredTargetProof: "target-b", [key]: wrong } })
await assert.rejects(value.run)
assert.equal(value.writes(), 0)
})
}
test("new forward source must match the enrolled observed seal", async () => {
const value = await fixture()
Object.assign(value.enrolled, { candidateId: "b", targetProof: "observed-b", transition: { ...sourceTransition, sourceDesiredTargetProof: "target-b" } })
Object.assign(value.input, { originalCandidateId: "b", originalTargetProof: "target-b" })
await assert.rejects(value.run)
assert.equal(value.writes(), 0)
value.input.originalTargetProof = "observed-b"
await value.run()
})
test("inverse with B but no authenticated transition is refused", async () => { const value = await fixture(); value.recovery(); Object.assign(value.enrolled, { candidateId: "b", targetProof: "target-b" }); await assert.rejects(value.run) })
test("inverse with another configuration checkpoint is refused", async () => { const value = await fixture(); value.recovery(); Object.assign(value.enrolled, { candidateId: "b", targetProof: "target-b", transition: { operationId: "source-op", checkpointProof: "other-checkpoint" } }); await assert.rejects(value.run) })
test("inverse with another configuration checkpoint is refused", async () => { const value = await fixture(); value.recovery(); Object.assign(value.enrolled, { candidateId: "b", targetProof: "target-b", transition: { ...sourceTransition, checkpointProof: "other-checkpoint" } }); await assert.rejects(value.run) })
test("inverse with external C baseline is refused", async () => { const value = await fixture(); value.recovery(); value.enrolled.candidateId = "c"; await assert.rejects(value.run) })
test("inverse with another authenticated source identity is refused", async () => { const value = await fixture(); value.recovery(); value.metadata.serviceInput.sourceProof = "other"; await assert.rejects(value.run) })
test("inverse with source dependency drift is refused", async () => { const value = await fixture(); value.recovery(); value.source.configuration.packet.dependencyProof = "other"; await assert.rejects(value.run) })
@@ -49,8 +49,8 @@ export async function assertActiveRoutingProfileWriter(serverId:number) {
const principal=await routingControllerBaselinePrincipal(row.state?.baseline),connection=await effectiveMcpConnection(principal)
const baseline=await authenticateRoutingControllerBaseline(row.state?.baseline,row.id,row.version,routeAiProfileSchema.parse(row.config),row.grant_expires_at.toISOString(),{principal,connection})
const enrolled=baseline.value.packet
const matches=(identity:{nativeId:unknown;candidateId:unknown;targetProof:unknown;dependencyProof:unknown;sourceProofs:unknown}) =>
identity.nativeId===enrolled.nativeId && identity.candidateId===enrolled.candidateId && identity.targetProof===enrolled.targetProof &&
const matches=(identity:{nativeId:unknown;candidateId:unknown;targetProof:unknown;dependencyProof:unknown;sourceProofs:unknown}, targetProof=enrolled.targetProof) =>
identity.nativeId===enrolled.nativeId && identity.candidateId===enrolled.candidateId && identity.targetProof===targetProof &&
identity.dependencyProof===enrolled.dependencyProof && fingerprint(identity.sourceProofs)===fingerprint(enrolled.sourceProofs)
if(metadata.serviceName==="routing.community"){
const prepared=await loadRoutingMainTransitionPreparation(input)
@@ -68,8 +68,10 @@ export async function assertActiveRoutingProfileWriter(serverId:number) {
const packet=recoverySource.configuration.packet
const common={nativeId:packet.nativeId,dependencyProof:packet.dependencyProof,sourceProofs:packet.contextSources}
const original=matches({...common,candidateId:packet.originalCandidateId,targetProof:packet.originalTargetProof})
const ownDesired=matches({...common,candidateId:packet.desiredCandidateId,targetProof:packet.desiredTargetProof}) &&
enrolled.transition?.operationId===recoverySource.row.id && enrolled.transition?.checkpointProof===recoverySource.configuration.checkpointProof
const transition=enrolled.transition
const ownDesired=!!transition && transition.operationId===recoverySource.row.id && transition.planId===recoverySource.row.source_plan_id &&
transition.checkpointProof===recoverySource.configuration.checkpointProof &&
matches({...common,candidateId:packet.desiredCandidateId,targetProof:packet.desiredTargetProof},transition.sourceDesiredTargetProof??enrolled.targetProof)
if(!original&&!ownDesired)throw deny()
}
}
@@ -162,7 +162,7 @@ export async function acceptMainBaselineRuntime(actor: AuthUser) {
await pool.query("INSERT INTO mcp_routing_observations(id,src_server_id,dst_server_id,src_interface,dst_interface,family,routing_table,ping_ms,loss_pct,dl_mbps,ul_mbps,ping_at,bandwidth_at,context_verified,source) VALUES($1,$2,$2,$3,'peer','ipv4','main',1,0,100,100,now(),now(),true,'fixture')", [observationId, sid, iface])
await attestRoutingFixture(observationId)
}
const writeAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Main Route context write", mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read", "mm:network:write", "mm:filters:write"], scope: { serverIds: [sid], operationIds: ["routing.analyze", "routing.community.switch", "routing.profiles.save", "routing.profiles.read", "routing.path.verify"], allowGlobal: false }, limits: { callsPerMinute: 120 } }), writerActor)
const writeAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Main Route context write", mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read", "mm:network:write", "mm:filters:write"], scope: { serverIds: [sid], operationIds: ["routing.analyze", "routing.community.switch", "routing.profiles.save", "routing.profiles.read", "routing.controller.read", "routing.controller.update", "routing.path.verify"], allowGlobal: false }, limits: { callsPerMinute: 120 } }), writerActor)
writeConnectionId = writeAccess.connection.id
const writer = await authenticateMcpToken(writeAccess.token)
const automatic = routeAiProfileSchema.parse({ ...mainProfile, autoRecovery: true, candidates: mainProfile.candidates.map(candidate => ({ ...candidate, controlProbe: base.candidates[0].controlProbe })) })
@@ -226,6 +226,22 @@ export async function acceptMainBaselineRuntime(actor: AuthUser) {
console.log("TARGETED_PUBLIC_OVERLAP_TOCTOU_NATIVE0_PASS",JSON.stringify({newProfilesApprovedBeforeEitherEnabled:2,firstActivationApplied:true,secondTerminalCode:overlapTerminal.error_code,secondCasRows:0,enabledProfiles:1,newPrepareRefused:true,nativeWrites:0}))
const publicProfile=await invokeMcpTool(writer,"mm_route_profiles_list",{})
assert.ok(!JSON.stringify(publicProfile).includes(controller.state.baseline.proof),"Public profile DTO must omit baseline proof")
const beforeControllerRead = requests
const controllerView = await invokeMcpTool(writer, "mm_route_controller_get", { profileId: controller.id }) as { id: string; enabled: boolean; state: Record<string, unknown> }
assert.equal(controllerView.id, controller.id)
assert.equal(controllerView.enabled, true)
assert.equal(controllerView.state.baseline, undefined)
const hiddenConfig = { ...mainProfile, candidates: mainProfile.candidates.map(candidate => ({ ...candidate, exitNodeId: sid + 1000 })) }
await pool.query("UPDATE mcp_routing_profiles SET config=$2::jsonb WHERE id=$1", [controller.id, JSON.stringify(hiddenConfig)])
try {
const notFound = (error: unknown) => error instanceof Error && "code" in error && error.code === "NOT_FOUND"
await assert.rejects(invokeMcpTool(writer, "mm_route_controller_get", { profileId: controller.id }), notFound)
await assert.rejects(invokeMcpTool(writer, "mm_route_profile_save_plan", { profileId: controller.id, profile: mainProfile, enabled: false, expiresAt: activation.grantExpiresAt }), notFound)
} finally {
await pool.query("UPDATE mcp_routing_profiles SET config=$2::jsonb WHERE id=$1", [controller.id, JSON.stringify(mainProfile)])
}
assert.equal(requests, beforeControllerRead)
console.log("✓ Route AI controller: exact public state, hidden dependencies reject read and replacement before RouterOS")
// Explicit out-of-application drift after enrollment, before a trusted
// private host prepares another persistent plan. MAC/authority stay valid.
policyComment=legacyComment+"-external-drift"
@@ -302,7 +318,8 @@ export async function acceptMainBaselineRuntime(actor: AuthUser) {
await assert.rejects(client.patch("/routing/filter/rule/*f",{rule:body}))
assert.equal(writes,beforeDenied,"Active controller refuses foreign native write before I/O")
// Disable through the same public human save/CAS service, not direct SQL.
const disable=await invokeMcpTool(writer,"mm_route_profile_save_plan",{profileId:controller.id,profile:mainProfile,enabled:false,expiresAt:activation.grantExpiresAt}) as {planId:string}
await assert.rejects(invokeMcpTool(writer, "mm_route_controller_update_plan", { profileId: randomUUID(), profile: mainProfile, enabled: false, expiresAt: activation.grantExpiresAt }), (error: unknown) => error instanceof Error && "code" in error && error.code === "NOT_FOUND")
const disable=await invokeMcpTool(writer,"mm_route_controller_update_plan",{profileId:controller.id,profile:mainProfile,enabled:false,expiresAt:activation.grantExpiresAt}) as {planId:string}
await approvePlan(disable.planId,writerActor,true)
assert.equal((await waitOperation((await applyPlan(writer,disable.planId,"runtime-main-baseline-disable")).id)).state,"applied")
// Native writer owns the REAL server lock first. Approved activation cannot
@@ -0,0 +1,142 @@
import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { mkdtemp, readFile, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { basename, dirname, join, resolve } from "node:path"
import { test, type TestContext } from "node:test"
import ts from "typescript"
import { z } from "zod"
import { scopedTargetRuleMatches } from "./routing-community-scoped-model.js"
import * as security from "./security.js"
import type * as Baseline from "./routing-controller-baseline.js"
// Run the actual baseline implementation and durable HMAC on a temporary key.
// Only the SQL/RouterOS/authority adapters supply synthetic snapshots.
async function fixture(t: TestContext) {
const root = resolve(tmpdir()), prefix = "mcp-controller-baseline-test-"
const state = await mkdtemp(join(root, prefix)), previous = process.env.MCP_STATE_DIR
process.env.MCP_STATE_DIR = state
t.after(async () => {
if (previous === undefined) delete process.env.MCP_STATE_DIR
else process.env.MCP_STATE_DIR = previous
assert.equal(dirname(resolve(state)), root)
assert(basename(state).startsWith(prefix))
await rm(state, { recursive: true })
})
const { fingerprint, privateSecretFingerprint } = security
const now = Date.now(), profileId = randomUUID(), planId = randomUUID(), operationId = randomUUID(), connectionId = randomUUID()
const grant = new Date(now + 120000).toISOString()
const principal = { connectionId, credentialId: randomUUID(), credentialExpiresAt: grant }
const profile = { serverId: 1, table: "tenant", probeVrf: "tenant", candidates: ["a", "b"].map(id => ({ id, jumpHostId: 1, exitNodeId: 1 })) }
const permissions = ["mm:network:read", "mm:network:write", "mm:filters:write"]
const connection = { id: connectionId, owner_id: "owner", generation: await security.accessGeneration(),
owner_valid_until: new Date(grant), owner_claims_issued_at: new Date(now), owner_permissions: permissions,
policy: { status: "active", mode: "operator", expiresAt: grant, permissions,
scope: { serverIds: [1], operationIds: ["routing.analyze", "routing.community.switch", "routing.path.verify"] } } }
const context = { principal, connection } as Parameters<typeof Baseline.prepareRoutingControllerBaseline>[4]
const originalRule = 'if (bgp-communities includes 65000:10) {\n set gw 192.0.2.1;\n set gw-interface "marker-gre-a";\n accept;\n}'
const desiredRule = originalRule.replace("192.0.2.1", "192.0.2.9").replace("marker-gre-a", "marker-gre-b")
const bareRule = desiredRule.replace('"marker-gre-b"', "marker-gre-b")
const signTarget = await security.privateFingerprinter("baseline-test-native-target")
const stableTarget = { ".id": "*1", chain: "input", comment: "managed" }
let observed = { ...stableTarget, rule: originalRule }, candidate = "a", failAfterRead = false, driftSourceAtFence = false
const targetProof = (rule: string) => signTarget(fingerprint({ ...observed, rule }))
const desiredTargetProof = targetProof(desiredRule), dependencyProof = "d".repeat(64), checkpointProof = "c".repeat(64)
const sourceProofs = [{ serverId: 1, proof: "a".repeat(64) }]
const receiptId = randomUUID(), requestSeal = "request-proof", networkSeal = "network-proof"
const input = { proofRevision: 3, profile, principal, ownerId: connection.owner_id, nativeId: "*1", originalCandidateId: "a",
originalTargetProof: targetProof(originalRule), desiredTargetProof, dependencyProof, contextSources: sourceProofs, candidateId: "b",
persistentProfileAuthority: { packet: { profileId, profileVersion: 1 } }, receiptId }
const step = { kind: "service", service: "routing.community.scoped", input }
const configuration = { checkpointProof, observedAt: new Date(now-100).toISOString(), packet: { revision: 2, operationId, stepIndex: 0,
nativeId: "*1", originalCandidateId: "a", originalTargetProof: input.originalTargetProof, desiredCandidateId: "b", desiredTargetProof,
dependencyProof, contextSources: sourceProofs, desiredRule } }
const nativeVerificationSeal = await privateSecretFingerprint(fingerprint({ request: requestSeal, dependency: dependencyProof, desired: desiredTargetProof,
candidate: "b", configuration: checkpointProof, network: networkSeal }), "route-ai-scoped-verified-v3")
const row = { id: operationId, plan_id: planId, state: "applied", step_state: "verified", operation_id: "routing.community.switch",
operation_version: 7, approved_by: "owner", created_at: new Date(now-200), intended_state: security.redact(step),
evidence: { sealed: await security.encryptSecret(JSON.stringify({ steps: [step], target: { scope: "servers", serverIds: [1] } })) },
result: { nativeVerificationSeal, configurationCheckpointProof: checkpointProof, networkCheckpointProof: networkSeal, receiptId,
proofRevision: 3, nativeWrites: 1, configurationVerified: true, postProbeStatus: "passed", controlProbeVerified: true } }
const providers: Record<string, unknown> = {
"./routing-main-transition.js": {},
"../../db/index.js": { pool: { query: async (_sql: string, ids: string[]) => {
assert.deepEqual(ids, [operationId, planId, connectionId])
return { rows: [failAfterRead ? { ...row, step_state: "unknown" } : row] }
} } },
"./registry.js": { findMcpOperation: () => ({ version: 7 }) },
"./routing-community-scoped-request.js": { assertScopedCommunityRequest: async () => requestSeal },
"./routing-profile-authority.js": { assertPersistentScopedProfileAuthority: async () => {},
routingProfileAuthorityBindingSchema: { parse: (value: unknown) => value } },
"./routing-community-scoped-checkpoint.js": { loadScopedConfigurationPacket: async () => configuration,
loadScopedNetworkCheckpoint: async () => ({ status: "passed", networkSeal }) },
"./routing-original-effects.js": {},
zod: { z }, "@mmapp/contracts/mcp-routing": { routeAiProfileSchema: { parse: (value: unknown) => value } },
"../../lib/permissions.js": { hasPermission: (set: string[], permission: string) => set.includes(permission) },
"./store.js": { effectiveMcpConnection: async () => connection },
"./security.js": security,
"./routing-community-context.js": { readRouteCommunityContext: async (_profile: unknown, options: { authorize(): Promise<void> }) => {
await options.authorize()
const target = { nativeId: observed[".id"], rawRow: observed, desiredTargetProof: targetProof }
return { before: { nativeRuleId: target.nativeId, scopedExecutionAvailable: true, currentCandidateId: candidate,
targetRuleProof: targetProof(observed.rule), dependencyProof, sourceProofs },
ownedTarget: { ...target, matchesRuleProof: (body: string, proof: unknown) => scopedTargetRuleMatches(target, body, proof) },
fence: async () => { await options.authorize(); if (driftSourceAtFence) failAfterRead = true } }
} },
}
const source = await readFile(new URL("./routing-controller-baseline.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
const baseline = {} as typeof Baseline
new Function("exports", "require", compiled)(baseline, (name: string) => { assert(providers[name], name); return providers[name] })
const original = await baseline.prepareRoutingControllerBaseline(profileId, 1, profile, grant, context)
const links = { planId, operationId, checkpointProof }
return { original, links, desiredTargetProof, targetProof, originalRule, desiredRule, bareRule, baseline,
advance: () => baseline.advanceRoutingControllerBaseline(original, profileId, 1, profile, grant, context, planId, operationId),
assertCurrent: (value: unknown) => baseline.assertRoutingControllerBaseline(value, profileId, 1, profile, grant, context),
driftAfterNativeRead: () => { driftSourceAtFence = true },
observe: (rule: string, metadata: Partial<typeof stableTarget> = {}, selected = "b") => { candidate = selected; observed = { ...stableTarget, ...metadata, rule } } }
}
test("controller signs an exact legacy-shaped next baseline when native target proof matches", async t => {
const f = await fixture(t)
await f.assertCurrent(f.original)
f.observe(f.desiredRule)
const next = await f.advance()
assert.equal(next.packet.targetProof, f.desiredTargetProof)
assert.deepEqual(next.packet.transition, f.links)
assert.equal(next.packet.previousProof, f.original.proof)
assert.deepEqual(await f.assertCurrent(next), next)
})
test("controller authenticates the source but enrolls the exact bare RouterOS readback", async t => {
const f = await fixture(t)
f.observe(f.bareRule)
const next = await f.advance()
assert.equal(next.packet.targetProof, f.targetProof(f.bareRule))
assert.notEqual(next.packet.targetProof, f.desiredTargetProof)
assert.deepEqual(next.packet.transition, { ...f.links, sourceDesiredTargetProof: f.desiredTargetProof })
assert.deepEqual(await f.assertCurrent(next), next)
const tampered = structuredClone(next)
tampered.packet.transition!.sourceDesiredTargetProof = "e".repeat(64)
await assert.rejects(f.baseline.routingControllerBaselinePrincipal(tampered), { code: "STATE_CONFLICT" })
f.observe(f.desiredRule)
await assert.rejects(f.assertCurrent(next), { code: "STATE_CONFLICT" })
f.observe(f.originalRule, {}, "a")
await assert.rejects(f.assertCurrent(next), { code: "STATE_CONFLICT" })
await f.assertCurrent(f.original)
})
test("controller refuses changed selected rule and drift in other native row fields", async t => {
const f = await fixture(t)
f.observe(f.bareRule.replace("marker-gre-b", "other"))
await assert.rejects(f.advance(), { code: "STATE_CONFLICT" })
f.observe(f.bareRule, { comment: "changed" })
await assert.rejects(f.advance(), { code: "STATE_CONFLICT" })
})
test("controller refuses baseline publication if the source operation changes after readback", async t => {
const f = await fixture(t)
f.observe(f.bareRule)
f.driftAfterNativeRead()
await assert.rejects(f.advance(), { code: "STATE_CONFLICT" })
})
@@ -23,7 +23,8 @@ const packetSchema = z.object({
nativeId: z.string().regex(/^\*[a-z0-9]+$/i), candidateId: z.string().min(1),
targetProof: proof, dependencyProof: proof,
sourceProofs: z.array(z.object({ serverId: z.number().int().positive(), proof }).strict()).min(1).max(1000),
observedAt: z.string().datetime(), previousProof: proof.optional(), transition: z.object({ planId: z.string().uuid(), operationId: z.string().uuid(), checkpointProof: proof }).strict().optional(),
observedAt: z.string().datetime(), previousProof: proof.optional(),
transition: z.object({ planId: z.string().uuid(), operationId: z.string().uuid(), checkpointProof: proof, sourceDesiredTargetProof: proof.optional() }).strict().optional(),
}).strict()
const baselineSchema = z.object({ packet: packetSchema, proof }).strict()
export type RoutingControllerBaseline = z.infer<typeof baselineSchema>
@@ -186,14 +187,18 @@ export async function advanceRoutingControllerBaseline(rawBaseline: unknown, pro
result.networkCheckpointProof !== network.networkSeal || result.receiptId !== input.receiptId || result.proofRevision !== input.proofRevision ||
result.nativeWrites !== 1 || result.configurationVerified !== true || result.postProbeStatus !== "passed" || result.controlProbeVerified !== true) throw fail()
const native = await readRouteCommunityContext(profile, { authorize, signal: context.signal, expectedSources: original.sourceProofs, selectedCandidateId: packet.desiredCandidateId })
if (!supportedExecution(profile,native.before) || native.ownedTarget?.nativeId !== original.nativeId || native.ownedTarget?.rawRow.rule !== packet.desiredRule ||
native.before.currentCandidateId !== packet.desiredCandidateId || native.before.targetRuleProof !== packet.desiredTargetProof ||
const targetProof = native.before.targetRuleProof
if (!supportedExecution(profile,native.before) || native.ownedTarget?.nativeId !== original.nativeId || native.before.nativeRuleId !== original.nativeId ||
!targetProof || !native.ownedTarget.matchesRuleProof(packet.desiredRule, packet.desiredTargetProof) || native.before.currentCandidateId !== packet.desiredCandidateId ||
native.before.dependencyProof !== original.dependencyProof || fingerprint(native.before.sourceProofs) !== fingerprint(original.sourceProofs)) throw fail()
await native.fence(); await authorize()
await assertPersistentScopedProfileAuthority(input, context.signal)
if (fingerprint(await readSource()) !== sourceIdentity) throw fail()
const next = packetSchema.parse({ ...original, candidateId: packet.desiredCandidateId, targetProof: packet.desiredTargetProof,
observedAt: new Date().toISOString(), previousProof: value.proof, transition: { planId, operationId, checkpointProof: configuration.checkpointProof } })
// Enroll the exact observed native seal for the next tick. The source seal
// still identifies this operation and its inverse when RouterOS omits quotes.
const next = packetSchema.parse({ ...original, candidateId: packet.desiredCandidateId, targetProof,
observedAt: new Date().toISOString(), previousProof: value.proof, transition: { planId, operationId, checkpointProof: configuration.checkpointProof,
...(targetProof !== packet.desiredTargetProof ? { sourceDesiredTargetProof: packet.desiredTargetProof } : {}) } })
return { packet: next, proof: await sign(next) }
}
@@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises"
import { randomUUID } from "node:crypto"
import ts from "typescript"
import { z } from "zod"
import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing"
import { routeAiControllerBlockReason, routeAiProfileSchema, usesScopedRouteAiContext } from "@mmapp/contracts/mcp-routing"
import { fingerprint, McpError } from "./security.js"
type Handler = { prepare?: (input: Record<string, unknown>, context: unknown) => Promise<Record<string, unknown>>; execute?: (input: Record<string, unknown>) => Promise<unknown> }
@@ -32,10 +32,10 @@ async function fixture() {
const profileModule = await load("./routing-profiles.ts", {
"./routing-controller-overlap.js": overlap, "./security.js": { fingerprint, McpError, accessGeneration: async () => "g" },
"./routing-controller-baseline.js": { prepareRoutingControllerBaseline: async () => { baselinePreparations++; return { packet: {}, proof: "inert-baseline" } }, assertRoutingControllerBaseline: async () => {}, routingControllerBaselinePrincipal: async () => principal, advanceRoutingControllerBaseline: async () => {} },
"./routing-community-public.js": { publicRoutingControllerState: () => ({}) }, "node:crypto": { randomUUID }, zod: { z }, "@mmapp/contracts/mcp-routing": { routeAiProfileSchema }, "../../db/index.js": { pool },
"./routing-community-public.js": { publicRoutingControllerState: () => ({}) }, "node:crypto": { randomUUID }, zod: { z }, "@mmapp/contracts/mcp-routing": { routeAiControllerBlockReason, routeAiProfileSchema, usesScopedRouteAiContext }, "../../db/index.js": { pool },
"./service-registry.js": { registerMcpService: (name: string, handler: Handler) => services.set(name, handler) },
"./store.js": { audit: async () => {}, validatePrincipal: async () => connection }, "./routing-model.js": { decideRoutingSwitch: () => ({}) },
"./routing-community-context-model.js": { routeContextExecutionReason: () => null },
"./path-proof-purpose.js": { COMMUNITY_PATH_MAX_AGE_MS: 90000 },
"./routing-controller-fence.js": { saveControllerProfileCas: async () => { casWrites++; return { id: "saved", version: 1 } } },
"./routing-reservation-store.js": { createRoutingReservationStore: () => { throw new Error("Overlap-only fixture must not read or mutate routing charges") } },
})
+113 -31
View File
@@ -4,7 +4,8 @@ import { z } from "zod"
import { dedupeVrfAliasSnapshots } from "../../services/vrf-production-action-binding.js"
import { pool } from "../../db/index.js"
import { hasManagedCommentPrefix } from "../../managed-markers.js"
import { readRosSnapshot, routerClient, type ConfigurationRow } from "./resources.js"
import { configurationRows, readRosSnapshot, readRoutingPolicySnapshot, resourceById, routerClient, rosSnapshotSourceProof, type ConfigurationRow, type RosSnapshot } from "./resources.js"
import { readRouterOsSnapshotGet } from "../../services/routeros-snapshot-read.js"
import { McpError, fingerprint } from "./security.js"
import { effectiveMcpConnection } from "./store.js"
import { hasPermission } from "../../lib/permissions.js"
@@ -19,7 +20,7 @@ import { readBgpSessionsForServer, readBgpCatalogForServer, readRawBgpSessionsFo
import { pollGreCatalog } from "../../services/gre-catalog-poll.js"
import { fetchServerFilters, readFilterRulesCatalog } from "../../routes/filters.js"
import { readRecursiveCatalogForServer, readRecursiveGatewayCatalogForServer, readRecursiveRoutesForServer, readRecursiveGatewaysForServer } from "../../routes/recursive-routes.js"
import { analyzeEcmpPreflight, preflightPaths, preflightObservation, type PreflightObservation, type PreflightSource } from "./ecmp-preflight.js"
import { analyzeEcmpPreflight, confirmedEcmpInterfaces, confirmedEcmpTable, duplicateEcmpAdjacency, ecmpAdjacency, preflightPaths, preflightObservation, type PreflightObservation, type PreflightSource } from "./ecmp-preflight.js"
import { readLatestInternetPathSnapshot } from "../../services/internet-path-read.js"
import { projectInternetPathObservation } from "../../services/internet-path-observation.js"
import { readOspfOptimizationPreview } from "../../routes/ospf.js"
@@ -27,6 +28,35 @@ import { readOspfOptimizationPreview } from "../../routes/ospf.js"
const serverId = z.number().int().positive(), objectId = z.string().min(1).max(100)
const text = z.string().min(1).max(100), family = z.enum(["ipv4", "ipv6"])
const bool = (value: unknown) => value === true || value === "true" || value === "yes"
export function checkRpfPolicy(serverId: number, snapshots: RosSnapshot[], mode: string) {
const find = (resource: string) => snapshots.find(snapshot => snapshot.serverId === serverId && snapshot.resource === resource)
const tables = find("routing_table"), vrfs = find("vrf"), rules = find("routing_policy:routing_rule"), mangle = find("routing_policy:ip_mangle")
if (!tables || !vrfs || !rules || !mangle || !find("ipv4_route")) {
throw new McpError("PLAN_EXPIRED", "План не содержит актуальные зависимости RPF; подготовьте его заново", 409)
}
const policyRouting = rules.rows.some(row => !bool(row.disabled))
|| mangle.rows.some(row => !bool(row.disabled) && (row.action === "mark-routing" || typeof row.action !== "string" || !row.action.trim()))
const separateTables = [...tables.rows, ...vrfs.rows].some(row => row.name !== "main")
if (mode === "strict" && (policyRouting || separateTables)) throw new McpError("STATE_CONFLICT", "Strict RPF требует main без отдельных таблиц, VRF и действующих правил policy routing", 409)
return policyRouting || separateTables
}
export async function readRpfDependencies(serverId: number, mode: string, sourceProof: string | undefined) {
if (!sourceProof) throw new McpError("STATE_CONFLICT", "Источник IPv4-настроек RPF не подтверждён", 409)
const [tables, vrfs, routes] = await Promise.all(
["routing_table", "vrf", "ipv4_route"].map(resource => readRosSnapshot(serverId, resource, undefined, sourceProof)),
)
const snapshots = [tables, vrfs, routes]
const warnings = ["RPF является IPv4-настройкой. Изменение может затронуть асимметричный трафик; снижение защиты требует компенсирующих правил."]
if (mode !== "no") {
const [rules, mangle] = await Promise.all(["routing_rule", "ip_mangle"].map(resource => readRoutingPolicySnapshot(serverId, resource, sourceProof)))
snapshots.push(rules, mangle)
if (checkRpfPolicy(serverId, snapshots, mode)) warnings.push("Обнаружена policy routing или отдельные таблицы. Проверьте обратный путь для трафика в каждой затронутой таблице.")
}
return { snapshots, warnings }
}
export function registerRoutingControls(register: typeof add) {
register("routing.ospf.application.preview","mm_ospf_application_preview","Модельный OSPF preview приложения с отдельными наблюдаемыми метриками","network","mm:network:read","read","servers",z.object({serverId:z.number().int().positive().safe(),pingWeight:z.number().int().min(0).max(100).default(60),peerServerIds:z.array(z.number().int().positive().safe()).max(10).refine(ids=>new Set(ids).size===ids.length).optional(),offset:z.number().int().min(0).max(100000).default(0),limit:z.number().int().min(1).max(200).default(100)}).strict(),{
read:async(input,context)=>{
@@ -95,11 +125,16 @@ export function registerRoutingControls(register: typeof add) {
register("routing.ecmp.preflight.read", "mm_ecmp_preflight_get", "Предварительная проверка ECMP: NAT/FastTrack/RPF/маркировки без записи", "network", "mm:network:read", "read", "servers", z.object({ serverId, family, routeIds: z.array(objectId).min(2).max(8).refine(ids => new Set(ids).size === ids.length) }).strict(), {
read: async input => {
const { client } = await routerClient(Number(input.serverId)), selectedFamily = input.family as "ipv4" | "ipv6"
const entries = await Promise.all(Object.entries(preflightPaths(selectedFamily)).map(async ([key, path]) => {
try { return [key, preflightObservation(await client.get<unknown>(path), key === "settings" || key === "connectionTracking")] as const }
catch { return [key, { rows: [], error: "Устройство не подтвердило чтение ресурса" }] as const }
}))
return { serverId: input.serverId, observedAt: new Date().toISOString(), ...analyzeEcmpPreflight(selectedFamily, input.routeIds as string[], Object.fromEntries(entries) as Record<PreflightSource, PreflightObservation>) }
const read = async (path: string, singleton = false): Promise<PreflightObservation> => {
try { return preflightObservation(await client.get<unknown>(path), singleton) }
catch { return { rows: [], error: "Устройство не подтвердило чтение ресурса" } }
}
const entries = await Promise.all(Object.entries(preflightPaths(selectedFamily)).map(async ([key, path]) => [key, await read(path, key === "settings" || key === "connectionTracking")] as const))
const observed = Object.fromEntries(entries) as Record<PreflightSource, PreflightObservation>
const ids = input.routeIds as string[]
const needsInventory = observed.routes.rows.some(row => ids.includes(String(row[".id"])) && typeof row["immediate-gw"] === "string" && row["immediate-gw"].length > 0 && !ecmpAdjacency(row["immediate-gw"]))
const inventory = needsInventory ? await read("/interface") : undefined
return { serverId: input.serverId, observedAt: new Date().toISOString(), ...analyzeEcmpPreflight(selectedFamily, ids, observed, inventory) }
},
})
const recursivePaging = z.object({ serverId, offset: z.number().int().min(0).max(100000).default(0), limit: z.number().int().min(1).max(200).default(100) }).strict()
@@ -202,48 +237,95 @@ export function registerRoutingControls(register: typeof add) {
},
})
register("routing.ecmp.update", "mm_ecmp_plan", "Равная стоимость маршрутов ECMP и hash policy", "network", "mm:network:write", "write", "servers", z.object({ serverId, family, routeIds: z.array(objectId).min(2).max(8).refine(ids => new Set(ids).size === ids.length), distance: z.number().int().min(1).max(255).default(1), hashPolicy: z.enum(["l3", "l4", "l3-inner"]).optional() }).strict(), {
version: 2,
version: 7,
prepare: async input => {
const sid = Number(input.serverId), resource = input.family === "ipv4" ? "ipv4_route" : "ipv6_route"
const { client } = await routerClient(sid), snapshot = await readRosSnapshot(sid, resource), ids = input.routeIds as string[]
const raw = await client.get<ConfigurationRow[]>(input.family === "ipv4" ? "/ip/route" : "/ipv6/route")
const sid = Number(input.serverId), ipv4 = input.family === "ipv4", resource = ipv4 ? "ipv4_route" : "ipv6_route"
const base = ipv4 ? "ip" : "ipv6", settingsResource = `${base}_settings`
const { client, server } = await routerClient(sid)
const sourceProof = await rosSnapshotSourceProof(server)
const assertSource = async () => {
if (await rosSnapshotSourceProof((await routerClient(sid)).server) !== sourceProof) {
throw new McpError("STATE_CONFLICT", "Источник маршрутов ECMP изменился при подготовке", 409)
}
}
const read = (resourceId: string) => readRosSnapshot(sid, resourceId, undefined, sourceProof)
const readNative = async (path: string) => {
const value = await readRouterOsSnapshotGet<unknown>({ client, path, assertCurrent: assertSource })
await assertSource()
return preflightObservation(value)
}
const snapshot = await read(resource), ids = input.routeIds as string[]
const observed = await readNative(`/${base}/route`)
if (observed.error) throw new McpError("MEASUREMENT_UNKNOWN", observed.error, 409)
if (fingerprint(configurationRows(resourceById(resource), observed.rows)) !== snapshot.fingerprint) {
throw new McpError("STATE_CONFLICT", "Каталог маршрутов ECMP изменился при подготовке", 409)
}
const raw = observed.rows
const rows = ids.map(id => raw.find(row => row[".id"] === id))
if (rows.some(row => !row || bool(row.dynamic) || bool(row.disabled) || bool(row.invalid) || !hasManagedCommentPrefix(String(row.comment ?? "")))) throw new McpError("PROTECTED_OBJECT", "ECMP требует разрешаемые статические маршруты приложения")
const routes = rows as ConfigurationRow[], first = routes[0]
if (routes.some(row => row["dst-address"] !== first["dst-address"] || String(row["routing-table"] ?? "main") !== String(first["routing-table"] ?? "main"))) throw new McpError("STATE_CONFLICT", "ECMP требует одинаковые prefix и routing table", 409)
if (new Set(routes.map(row => String(row.gateway))).size !== ids.length || routes.some(row => !row.gateway || !row["immediate-gw"])) throw new McpError("MEASUREMENT_UNKNOWN", "Нужны разные подтверждённые next-hop и immediate gateway", 409)
const gateways = routes.map(row => row.gateway)
if (gateways.some(gateway => typeof gateway !== "string" || !gateway.trim()) || new Set(gateways).size !== ids.length) throw new McpError("MEASUREMENT_UNKNOWN", "Нужны разные подтверждённые gateway", 409)
const immediate = routes.map(row => row["immediate-gw"])
let hops = immediate.map(value => ecmpAdjacency(value))
let interfaceIds: Map<string, string> | undefined
if (hops.some(hop => hop === null)) {
const inventory = await readNative("/interface")
if (inventory.error) throw new McpError("MEASUREMENT_UNKNOWN", inventory.error, 409)
interfaceIds = confirmedEcmpInterfaces(inventory.rows)
const names = new Set(interfaceIds.keys())
hops = immediate.map(value => ecmpAdjacency(value, names))
}
if (hops.some(hop => hop === null)) throw new McpError("MEASUREMENT_UNKNOWN", "Не подтверждён одиночный immediate gateway каждого маршрута", 409)
if (duplicateEcmpAdjacency(hops)) throw new McpError("STATE_CONFLICT", "Маршруты ECMP разрешаются в один immediate gateway", 409)
const steps: ChangeEvidence["steps"] = ids.map(id => ({ kind: "ros", step: { serverId: sid, resource, id, action: "update", fields: { distance: String(input.distance) } } }))
const snapshots = [snapshot]
const tableSnapshot = await readRosSnapshot(sid, "routing_table"), table = tableSnapshot.rows.find(row => row.name === String(first["routing-table"] ?? "main"))
if (!table || typeof table[".id"] !== "string" || bool(table.disabled)) throw new McpError("STATE_CONFLICT", "Таблица ECMP отсутствует или отключена", 409)
const tableSnapshot = await read("routing_table")
const table = confirmedEcmpTable(tableSnapshot.rows, String(first["routing-table"] ?? "main"))
if (!table) {
throw new McpError("STATE_CONFLICT", "Для ECMP нужна однозначно определённая активная таблица FIB", 409)
}
snapshots.push(tableSnapshot)
const objects: ChangeEvidence["objects"] = [...ids.map(id => ({ kind: resource, id, serverId: sid })), { kind: "routing_table", id: table[".id"], serverId: sid }]
const objects: ChangeEvidence["objects"] = [...ids.map(id => ({ kind: resource, id, serverId: sid })), { kind: "routing_table", id: table.id, serverId: sid }]
const [settings, vrf, ...impacts] = await Promise.all([
read(settingsResource),
read("vrf"),
...[`${base}_filter`, `${base}_nat`, `${base}_mangle`, `${base}_raw`, "routing_rule"].map(resource => readRoutingPolicySnapshot(sid, resource, sourceProof)),
])
snapshots.push(settings, vrf, ...impacts)
if (input.hashPolicy) {
const settingsResource = input.family === "ipv4" ? "ip_settings" : "ipv6_settings", key = input.family === "ipv4" ? "ipv4-multipath-hash-policy" : "multipath-hash-policy"
const settings = await readRosSnapshot(sid, settingsResource)
const key = ipv4 ? "ipv4-multipath-hash-policy" : "multipath-hash-policy"
if (!(key in (settings.rows[0] ?? {}))) throw new McpError("UNSUPPORTED_CAPABILITY", "Устройство не подтвердило выбранную hash policy", 409)
snapshots.push(settings); steps.push({ kind: "ros", step: { serverId: sid, resource: settingsResource, action: "update", fields: { [key]: String(input.hashPolicy) } } })
steps.push({ kind: "ros", step: { serverId: sid, resource: settingsResource, action: "update", fields: { [key]: String(input.hashPolicy) } } })
objects.push({ kind: settingsResource, id: "singleton", serverId: sid }, { kind: settingsResource, id: "new", serverId: sid })
}
const impacts = await Promise.all(["ip_settings", "ip_filter", "ip_nat", "ip_mangle"].map(resource => readRosSnapshot(sid, resource)))
snapshots.push(...impacts)
return { target: { scope: "servers", serverIds: [sid] }, objects, snapshots, steps, requiresHuman: true,
healthChecks: routes.flatMap(row => [{ serverId: sid, resource, id: String(row[".id"]), field: "resolved" as const, identity: { gateway: String(row.gateway), "immediate-gw": String(row["immediate-gw"]) } }, { serverId: sid, resource, id: String(row[".id"]), field: "active" as const, phase: "after" as const }]),
warnings: ["Равные стоимости проверяются как конфигурация. Распределение реального трафика требует отдельного наблюдения.", "Проверьте NAT, FastTrack, маркировки и асимметрию; RPF автоматически не меняется."] }
await assertSource()
const healthChecks = routes.flatMap((row, index) => {
const hop = hops[index], name = hop?.address === null ? hop.interface : null
const interfaceId = name ? interfaceIds?.get(name) : undefined
const nextHopInterface = name && interfaceId ? { name, id: interfaceId } : undefined
return [{ serverId: sid, resource, id: String(row[".id"]), field: "resolved" as const, identity: { gateway: String(row.gateway), "immediate-gw": String(row["immediate-gw"]) }, nextHopInterface },
{ serverId: sid, resource, id: String(row[".id"]), field: "active" as const, phase: "after" as const }]
})
return { target: { scope: "servers", serverIds: [sid] }, objects, snapshots, steps, requiresHuman: true, healthChecks,
warnings: ["Равные стоимости проверяются как конфигурация. Распределение реального трафика требует отдельного наблюдения.", ipv4 ? "Проверьте NAT, FastTrack, маркировки и асимметрию. Настройка RPF сохраняется." : "Проверьте IPv6 NAT, маркировки, правила маршрутизации и асимметрию."] }
},
})
register("routing.rpf.update", "mm_rpf_plan", "Явное изменение IPv4 reverse path filter", "network", "mm:network:write", "write", "servers", z.object({ serverId, mode: z.enum(["no", "loose", "strict"]), acknowledgeAsymmetry: z.literal(true) }).strict(), {
version: 2,
register("routing.rpf.update", "mm_rpf_plan", "Явное изменение IPv4 reverse path filter", "network", "mm:network:write", "write", "servers", z.object({ serverId, family: family.default("ipv4"), mode: z.enum(["no", "disabled", "loose", "strict"]), acknowledgeAsymmetry: z.literal(true) }).strict(), {
version: 4,
prepare: async input => {
const sid = Number(input.serverId), snapshots = await Promise.all(["ip_settings", "routing_table", "vrf", "ipv4_route"].map(resource => readRosSnapshot(sid, resource)))
if (!("rp-filter" in (snapshots[0].rows[0] ?? {}))) throw new McpError("UNSUPPORTED_CAPABILITY", "RPF не подтверждён устройством", 409)
if (input.mode === "strict" && (snapshots[1].rows.some(row => row.name !== "main") || snapshots[2].rows.some(row => row.name !== "main"))) throw new McpError("STATE_CONFLICT", "Strict RPF не допускается этим планом при нескольких таблицах или VRF", 409)
return { target: { scope: "servers", serverIds: [sid] }, objects: [{ kind: "server", id: String(sid), serverId: sid }, { kind: "ip_settings", id: "singleton", serverId: sid }, { kind: "ip_settings", id: "new", serverId: sid }], snapshots, steps: [{ kind: "ros", step: { serverId: sid, resource: "ip_settings", action: "update", fields: { "rp-filter": String(input.mode) } } }], requiresHuman: true,
warnings: ["RPF является IPv4-настройкой. Изменение может затронуть асимметричный трафик; снижение защиты требует компенсирующих правил."] }
if (input.family === "ipv6") throw new McpError("UNSUPPORTED_CAPABILITY", "RouterOS rp-filter доступен только для IPv4", 409)
const sid = Number(input.serverId)
const mode = input.mode === "disabled" ? "no" : String(input.mode)
const settings = await readRosSnapshot(sid, "ip_settings")
if (!("rp-filter" in (settings.rows[0] ?? {}))) throw new McpError("UNSUPPORTED_CAPABILITY", "RPF не подтверждён устройством", 409)
const { snapshots, warnings } = await readRpfDependencies(sid, mode, settings.sourceProof)
return { target: { scope: "servers", serverIds: [sid] }, objects: [{ kind: "server", id: String(sid), serverId: sid }, { kind: "ip_settings", id: "singleton", serverId: sid }, { kind: "ip_settings", id: "new", serverId: sid }], snapshots: [settings, ...snapshots], steps: [{ kind: "ros", step: { serverId: sid, resource: "ip_settings", action: "update", fields: { "rp-filter": mode } } }], requiresHuman: true, warnings }
},
})
register("routing.vrf.update", "mm_vrf_plan", "Проверенный состав существующего VRF", "network", "mm:network:write", "write", "servers", z.object({ serverId, id: objectId, interfaces: z.array(text).min(1).max(100).refine(values => new Set(values).size === values.length) }).strict(), {
version: 3,
version: 4,
prepare: async (input, context) => {
const sid = Number(input.serverId), { client } = await routerClient(sid), snapshot = await readRosSnapshot(sid, "vrf"), current = snapshot.rows.find(row => row[".id"] === input.id)
if (!current || current.name === "main" || !hasManagedCommentPrefix(String(current.comment ?? ""))) throw new McpError("PROTECTED_OBJECT", "Изменять состав можно только у существующего VRF приложения")
@@ -16,7 +16,7 @@ const originalSecurity = fs.readFileSync(path.join(root, 'backend/src/modules/mc
const securityAst = ts.createSourceFile('security.ts', originalSecurity, ts.ScriptTarget.Latest, true);
const selected = securityAst.statements.filter(node => ts.isFunctionDeclaration(node) && ['fingerprint', 'redact'].includes(node.name?.text)).map(node => node.getText(securityAst)).join('\n');
const securityExports = {};
const securityBody = ts.transpileModule('const sensitive = /password|passwd|secret|private.?key|preshared|pre.shared|authorization|auth.?key|authentication.?key|tcp.?md5.?key|api.?key|certificate.?contents|token|ciphertext/i; const MAX_MCP_PUBLIC_STRING_LENGTH=16000;\n' + selected, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText;
const securityBody = ts.transpileModule('const sensitive = /password|passwd|secret|private.?key|preshared|pre.shared|authorization|auth.?key|authentication.?key|tcp.?md5.?key|api.?key|certificate.?contents|token|ciphertext/i; const MAX_MCP_PUBLIC_STRING_LENGTH=16000; const MAX_MCP_PUBLIC_ARRAY_ITEMS=500;\n' + selected, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText;
new Function('exports', 'tokenDigest', securityBody)(securityExports, sha);
const { fingerprint, redact } = securityExports;
class McpError extends Error { constructor(code, message) { super(message); this.code = code; } }
@@ -37,7 +37,7 @@ function fixture(service='routing.community') {
const principal={connectionId:id(3), credentialId:id(4), credentialExpiresAt:'2026-11-02T13:00:00.000Z'};
const authority={packet:{profileId:id(5),profileVersion:7,connectionId:id(3),credentialId:id(4),ownerId:'owner'}, proof:'a'.repeat(64)};
const metadata={operationId:id(1),planId:id(2),operationName:'routing.community.switch',operationVersion:7,ownerId:'owner',phase:'forward',stepIndex:0,serviceName:service,principal,policyVersion:2,generation:'generation',originalExpiresAt:now.getTime()+60000,sealedEvidenceProof:sha('evidence'),sealedPayloadProof:sha('payload'),serviceInput:{profile:{autoRecovery:false},persistentProfileAuthority:authority}};
const source={operation_state:'running',plan_state:'executing',policy_version:2,generation:'generation',approved_by:'human',observed_at:now,evidence_sealed:'evidence',payload_sealed:'payload',step_state:'intent',intended_state:redact({kind:'service',service,input:metadata.serviceInput})};
const source={operation_state:'running',plan_state:'executing',policy_version:2,generation:'generation',approved_by:service==='routing.community.scoped'?'owner':'human',observed_at:now,evidence_sealed:'evidence',payload_sealed:'payload',step_state:'intent',intended_state:redact({kind:'service',service,input:metadata.serviceInput})};
const binding={revision:1,operationId:id(1),planId:id(2),stepIndex:0,profileId:id(5),profileVersion:7,connectionId:id(3),ownerId:'owner',principal,service,operationVersion:7,authorityProof:authority.proof,evidenceProof:fingerprint('evidence'),payloadProof:fingerprint('payload'),scope:[1],createdAt:now.toISOString(),chargeUnits:1};
const row={source_operation_id:id(1),source_plan_id:id(2),source_step_index:0,profile_id:id(5),connection_id:id(3),charge_units:1,binding_sealed:null,binding_proof:null,created_at:now};
let chargeRows=[row], sourceRows=[source], reservedRows=[], decryptCalls=0, nativeWrites=0, keyUnavailable=false;
@@ -59,6 +59,7 @@ function fixture(service='routing.community') {
run:async()=>{await verifier(db,metadata,'owner');nativeWrites++;},writes:()=>nativeWrites,decryptCalls:()=>decryptCalls};
}
for(const service of ['routing.community','routing.community.scoped'])test(`exact ${service} running charge passes without mutation`,async()=>{const f=fixture(service);await f.run();assert.equal(f.writes(),1);assert.equal(f.queries.length,3);assert.ok(f.queries.every(({sql})=>sql.startsWith('SELECT ')));assert.ok(f.queries[0].sql.includes('LIMIT 2'));});
test('scoped transport refuses delegated approval before reading the charge',async()=>{const f=fixture('routing.community.scoped');f.source.approved_by='delegated-admin';await assert.rejects(f.run);assert.equal(f.writes(),0);assert.equal(f.queries.length,1);});
const mutations=[
['missing charge',f=>f.noCharge()],['duplicate charge',f=>f.duplicateCharge()],['reserved overlap',f=>f.reserved()],['ambiguous source',f=>f.ambiguousSource()],
['pending operation',f=>f.source.operation_state='pending'],['applied operation',f=>f.source.operation_state='applied'],['terminal step',f=>f.source.step_state='verified'],['draft plan',f=>f.source.plan_state='draft'],['missing approval',f=>f.source.approved_by=null],['policy drift',f=>f.source.policy_version++],['generation drift',f=>f.source.generation='other'],
@@ -0,0 +1,60 @@
import type { ChangeEvidence } from "./action-types.js"
import { confirmedEcmpInterfaces, preflightObservation } from "./ecmp-preflight.js"
import { resourceById, routerClient, rosSnapshotSourceProof } from "./resources.js"
import { McpError } from "./security.js"
import { readRouterOsSnapshotGet } from "../../services/routeros-snapshot-read.js"
export async function verifyHealthChecks(evidence: Pick<ChangeEvidence, "healthChecks" | "snapshots">, serverIds: number[], afterWrite: boolean, authorize?: () => Promise<unknown>) {
const cache = new Map<string, unknown[]>()
const interfaces = new Map<number, Map<string, string>>()
for (const check of evidence.healthChecks ?? []) {
if (check.phase === "after" && !afterWrite) continue
if (!serverIds.includes(check.serverId)) throw new McpError("SERVER_DENIED", "Проверка находится вне плана")
if (check.field === "state" && check.resource !== "ospf_neighbor") throw new McpError("INVALID_INPUT", "Неизвестный критерий здоровья", 409)
if (check.field === "bfd-up" && check.resource !== "bfd_session") throw new McpError("INVALID_INPUT", "Неизвестный критерий BFD", 409)
if (check.field === "resolved" && !["ipv4_route", "ipv6_route"].includes(check.resource)) throw new McpError("INVALID_INPUT", "Неизвестный критерий next-hop", 409)
const key = `${check.serverId}:${check.resource}`
if (!cache.has(key)) {
await authorize?.()
const { client, server } = await routerClient(check.serverId)
const path = resourceById(check.resource).path
const isRoute = check.resource === "ipv4_route" || check.resource === "ipv6_route"
let raw: unknown
if (isRoute) {
const expected = evidence.snapshots.find(snapshot => snapshot.serverId === check.serverId && snapshot.resource === check.resource)?.sourceProof
if (!expected) throw new McpError("PLAN_EXPIRED", "Не подтверждён источник проверки next-hop", 409)
const compareSource = async (current: typeof server) => {
if (await rosSnapshotSourceProof(current) !== expected) {
throw new McpError("STATE_CONFLICT", "Источник проверки next-hop изменился", 409)
}
}
const assertCurrent = async () => {
await authorize?.()
await compareSource((await routerClient(check.serverId)).server)
}
await compareSource(server)
raw = await readRouterOsSnapshotGet({ client, path, assertCurrent })
await assertCurrent()
if (!interfaces.has(check.serverId) && evidence.healthChecks?.some(item => item.serverId === check.serverId && item.resource === check.resource && item.nextHopInterface)) {
const inventory = preflightObservation(await readRouterOsSnapshotGet({ client, path: "/interface", assertCurrent }))
await assertCurrent()
if (inventory.error) throw new McpError(afterWrite ? "VERIFICATION_FAILED" : "STATE_CONFLICT", "Каталог интерфейсов next-hop не подтверждён", 409)
interfaces.set(check.serverId, confirmedEcmpInterfaces(inventory.rows))
}
} else {
raw = await client.get<unknown>(path)
}
await authorize?.()
if (!Array.isArray(raw)) throw new McpError("STATE_UNAVAILABLE", "Ответ проверки здоровья не подтверждён", 503)
cache.set(key, raw)
}
const rows = cache.get(key)!.filter((value): value is Record<string, unknown> => !!value && typeof value === "object" && (value as Record<string, unknown>)[".id"] === check.id)
const row = rows.length === 1 ? rows[0] : undefined
const identityMatches = row && Object.entries(check.identity ?? {}).every(([key, value]) => row[key] === value)
if (check.nextHopInterface && interfaces.get(check.serverId)?.get(check.nextHopInterface.name) !== check.nextHopInterface.id) {
throw new McpError(afterWrite ? "VERIFICATION_FAILED" : "STATE_CONFLICT", "Интерфейс next-hop изменился после подготовки", 409)
}
const healthy = identityMatches && row && (check.field === "resolved" ? typeof row["immediate-gw"] === "string" && row["immediate-gw"].trim().length > 0 && ![true, "true", "yes"].includes(row.invalid as boolean | string) && ![true, "true", "yes"].includes(row.disabled as boolean | string) : check.field === "bfd-up" ? String(row.state).toLowerCase() === "up" : check.field === "state" ? String(row.state).toLowerCase() === "full" : [true, "true", "yes"].includes(row[check.field] as boolean | string))
if (!healthy) throw new McpError(afterWrite ? "VERIFICATION_FAILED" : "STATE_CONFLICT", afterWrite ? "После записи не подтверждён next-hop, Full OSPF или Up BFD" : "Работоспособность next-hop/OSPF/BFD изменилась после подготовки", 409)
}
}
@@ -57,7 +57,7 @@ function cursor(rows:unknown[],onFetch?:()=>void) {
test("historical cursor authenticates more than two pages of genuine nonpersistent classifications",async()=>{
const dir=await mkdtemp(join(tmpdir(),"mm-manual-history-unit-")),previous=process.env.MCP_STATE_DIR;process.env.MCP_STATE_DIR=dir
try {
const rows=[];for(let n=0;n<25;n++)rows.push((await fixture(false,n%2?6:7)).row)
const rows=[];for(let n=0;n<25;n++)rows.push((await fixture(false,6+n%3)).row)
const f=cursor(rows)
await assertLegacyManualHistoryWithinOwner(f.db,randomUUID(),at,()=>{})
assert.equal(f.calls.filter(sql=>sql.startsWith("FETCH")).length,3)
@@ -76,8 +76,10 @@ test("historical authentication, active persistent refusal, future rejection and
await assertLegacyManualHistoryWithinOwner(cursor([f.row]).db,f.profileId,"2026-11-01T13:01:00.001Z",()=>{})
const expiredTamper={...f.row,source_evidence:null}
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([expiredTamper]).db,f.profileId,"2026-11-01T14:00:00.000Z",()=>{}))
const current={...f.row,recovery_version:7}
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([current]).db,f.profileId,"2026-11-01T14:00:00.000Z",()=>{}),/lacks durable charge/)
for (const version of [7,8]) {
const current={...f.row,recovery_version:version}
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([current]).db,f.profileId,"2026-11-01T14:00:00.000Z",()=>{}),/lacks durable charge/)
}
const stripped={...f.input};delete stripped.persistentProfileAuthority
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([{...f.row,source_evidence:await encryptSecret(JSON.stringify({steps:[{kind:"service",service:"routing.community.scoped",input:stripped}]}))}]).db,f.profileId,at,()=>{}))
const future={...f.intent,createdAt:"2026-11-01T12:00:00.001Z"}
@@ -112,14 +114,14 @@ test("state transfer cap MODEL refuses oversized or NULL history as a whole, wit
}finally{if(previous===undefined)delete process.env.MCP_STATE_DIR;else process.env.MCP_STATE_DIR=previous;await rm(dir,{recursive:true,force:true})}
})
async function retentionFixture() {
const f=await fixture(true,7), authority=f.input.persistentProfileAuthority as {packet:{profileVersion:number;scope:number[]};proof:string}
async function retentionFixture(version = 7) {
const f=await fixture(true,version), authority=f.input.persistentProfileAuthority as {packet:{profileVersion:number;scope:number[]};proof:string}
const payload=await encryptSecret(JSON.stringify({MODEL:true}))
const row={...f.row,recovery_payload:payload,authority_profile_version:1}
const binding=manualInverseReceiptSchema.shape.binding.parse({revision:1,recoveryOperationId:f.intent.operationId,recoveryPlanId:row.recovery_plan_id,
sourceOperationId:f.intent.sourceOperationId,sourcePlanId:row.source_plan_id,sourceStepIndex:0,profileId:f.profileId,profileVersion:1,
connectionId:f.intent.connectionId,ownerId:f.intent.ownerId,originalPrincipal:f.input.principal,recoveryPrincipal:f.input.principal,
operationVersion:7,sourceVersion:7,authorityProof:authority.proof,sourceProof:f.intent.sourceProof,sourceRequestProof:f.input.requestProof,
operationVersion:version,sourceVersion:7,authorityProof:authority.proof,sourceProof:f.intent.sourceProof,sourceRequestProof:f.input.requestProof,
recoveryRequestProof:f.intent.requestSeal,sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),
recoveryPayloadProof:fingerprint(payload),scope:authority.packet.scope,createdAt:at,chargeUnits:1})
const receipt={binding,proof:await sign(binding,"routing-manual-inverse-charge-v1")}
@@ -136,6 +138,7 @@ function retentionOwner(f:Awaited<ReturnType<typeof retentionFixture>>,present=t
calls.push(sql)
if(sql==='SHOW transaction_isolation')return{rows:[{transaction_isolation:'read committed'}]}
if(sql.includes('pg_advisory_xact_lock'))return{rows:[]}
if(sql.includes('FROM mcp_settings WHERE id=1 FOR SHARE'))return{rows:[{enabled:true,requestsRetentionDays:1,auditRetentionDays:180}]}
if(sql.includes('clock_timestamp'))return{rows:[{at:new Date('2026-11-03T12:00:00.000Z')}]}
if(sql.startsWith('DECLARE'))return{rows:[]}
if(sql.startsWith('FETCH'))return{rows:[f.row]}
@@ -153,17 +156,21 @@ function retentionOwner(f:Awaited<ReturnType<typeof retentionFixture>>,present=t
test('retention MODEL intent2 authenticates SQL clock after parent GC; unknown noReplay anchor survives',async()=>{
const dir=await mkdtemp(join(tmpdir(),'mm-intent2-retention-')),previous=process.env.MCP_STATE_DIR;process.env.MCP_STATE_DIR=dir
try {
const f=await retentionFixture(),owner=retentionOwner(f),before=fingerprint(f.row)
// SQL aging and the transaction owner below are MODELS, never genuine SQL.
await assertLegacyManualHistoryWithinOwner(owner.db,f.profileId,'2026-11-03T12:00:00.000Z',()=>{})
assert.deepEqual(await retainManualInverseChargesWithinOwner(owner.db,1),{deleted:1})
assert.equal(owner.deleted.length,1);assert.equal(fingerprint(f.row),before)
for(const profile of [f.profileId,randomUUID()])await assertLegacyManualHistoryWithinOwner(owner.db,profile,'2026-11-03T12:00:00.000Z',()=>{})
assert(owner.calls.findIndex(s=>s.includes('pg_advisory_xact_lock'))<owner.calls.findIndex(s=>s.startsWith('SELECT c.recovery_operation_id')))
const candidate=owner.calls.find(s=>s.startsWith('SELECT c.recovery_operation_id'))!
assert(candidate.indexOf("state IN('pending','running')")<candidate.indexOf('LIMIT 100'))
assert(candidate.includes('r.authority_profile_version IS NOT NULL'))
assert.equal(f.intent.revision,2);assert.equal(await decryptSecretExisting(f.row.intent_sealed),JSON.stringify(f.intent))
for (const version of [7,8]) {
const f=await retentionFixture(version),owner=retentionOwner(f),before=fingerprint(f.row)
// SQL aging and the transaction owner below are MODELS, never genuine SQL.
await assertLegacyManualHistoryWithinOwner(owner.db,f.profileId,'2026-11-03T12:00:00.000Z',()=>{})
assert.deepEqual(await retainManualInverseChargesWithinOwner(owner.db),{deleted:1})
assert.equal(owner.deleted.length,1);assert.equal(fingerprint(f.row),before)
for(const profile of [f.profileId,randomUUID()])await assertLegacyManualHistoryWithinOwner(owner.db,profile,'2026-11-03T12:00:00.000Z',()=>{})
const admission=owner.calls.findIndex(s=>s.includes('pg_advisory_xact_lock'))
const settings=owner.calls.findIndex(s=>s.includes('FROM mcp_settings WHERE id=1 FOR SHARE'))
assert(admission>=0 && admission<settings && settings<owner.calls.findIndex(s=>s.startsWith('SELECT c.recovery_operation_id')))
const candidate=owner.calls.find(s=>s.startsWith('SELECT c.recovery_operation_id'))!
assert(candidate.indexOf("state IN('pending','running')")<candidate.indexOf('LIMIT 100'))
assert(candidate.includes('r.authority_profile_version IS NOT NULL'))
assert.equal(f.intent.revision,2);assert.equal(await decryptSecretExisting(f.row.intent_sealed),JSON.stringify(f.intent))
}
}finally{if(previous===undefined)delete process.env.MCP_STATE_DIR;else process.env.MCP_STATE_DIR=previous;await rm(dir,{recursive:true,force:true})}
})
@@ -176,12 +183,17 @@ test('retention MODEL refuses recent/future/malformed copy, present corrupt pare
f.parent.binding_proof='0'.repeat(64)
await assert.rejects(assertLegacyManualHistoryWithinOwner(retentionOwner(f).db,f.profileId,'2026-11-03T12:00:00.000Z',()=>{}),/MAC/)
const wrong=await retentionFixture();wrong.row.authority_profile_version=2;const o=retentionOwner(wrong)
await assert.rejects(retainManualInverseChargesWithinOwner(o.db,1),/hint/);assert.equal(o.deleted.length,0)
await assert.rejects(retainManualInverseChargesWithinOwner(o.db),/hint/);assert.equal(o.deleted.length,0)
const tamper=await retentionFixture();tamper.intent.manualInverseCharge.proof='0'.repeat(64)
tamper.row.intent_sealed=await encryptSecret(JSON.stringify(tamper.intent));tamper.row.intent_proof=await sign(tamper.intent,'route-ai-scoped-recovery-intent-v1')
const t=retentionOwner(tamper);await assert.rejects(retainManualInverseChargesWithinOwner(t.db,1),/MAC/);assert.equal(t.deleted.length,0)
const t=retentionOwner(tamper);await assert.rejects(retainManualInverseChargesWithinOwner(t.db),/MAC/);assert.equal(t.deleted.length,0)
const legacy=await fixture(true,7)
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([legacy.row]).db,legacy.profileId,'2026-11-03T12:00:00.000Z',()=>{}),/lacks durable/)
const wrongVersion=await retentionFixture(7);wrongVersion.row.recovery_version=8
const mismatched=retentionOwner(wrongVersion)
await assert.rejects(assertLegacyManualHistoryWithinOwner(mismatched.db,wrongVersion.profileId,'2026-11-03T12:00:00.000Z',()=>{}),/signed allocation/)
await assert.rejects(retainManualInverseChargesWithinOwner(mismatched.db),/hint/)
assert.equal(mismatched.deleted.length,0)
// Revision2 payload proof cannot be replaced by an authentic stamp from a different payload.
const payload=await retentionFixture();payload.row.recovery_payload=await encryptSecret('{}')
await assert.rejects(assertLegacyManualHistoryWithinOwner(retentionOwner(payload,false).db,payload.profileId,'2026-11-03T12:00:00.000Z',()=>{}),/payload/)
@@ -40,7 +40,7 @@ export async function classifyManualInverseHistory(row:HistoricalRow,observedAt:
intent.receiptId!==row.recovery_receipt_id || intent.validUntil!==iso(row.deadline) ||
row.source_connection_id!==intent.connectionId || row.recovery_connection_id!==intent.connectionId ||
row.source_action!=="routing.community.switch" || row.source_version!==7 ||
row.recovery_action!=="operations.recover" || ![6,7].includes(row.recovery_version??0) ||
row.recovery_action!=="operations.recover" || ![6,7,8].includes(row.recovery_version??0) ||
!["partial","applied"].includes(row.source_state??"")) throw fail("Historical manual intent/source identity differs")
const source=await sealedJson(row.source_evidence) as ChangeEvidence, recovery=await sealedJson(row.recovery_evidence) as ChangeEvidence
const step=source.steps?.[0], inverse=recovery.steps?.[0]
@@ -141,7 +141,7 @@ export async function assertLegacyManualHistoryWithinOwner(db:PoolClient,profile
// Authenticate any durable inverse row before treating this old manual
// identity as represented by the normal profile ledger.
const {authenticateManualInverseHistoryWithinOwner}=await import("./routing-manual-inverse-store.js")
if(await authenticateManualInverseHistoryWithinOwner(db,classified.intent,classified.authority,observedAt,{sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),recoveryPlanId:row.recovery_plan_id,sourcePlanId:row.source_plan_id,sourceRequestProof:classified.sourceRequestProof,sourcePrincipal:classified.sourcePrincipal,recoveryPrincipal:classified.recoveryPrincipal})) continue
if(await authenticateManualInverseHistoryWithinOwner(db,classified.intent,classified.authority,observedAt,{sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),recoveryPlanId:row.recovery_plan_id,recoveryVersion:row.recovery_version,sourcePlanId:row.source_plan_id,sourceRequestProof:classified.sourceRequestProof,sourcePrincipal:classified.sourcePrincipal,recoveryPrincipal:classified.recoveryPrincipal})) continue
if(row.recovery_version!==6) throw fail("Current persistent manual inverse lacks durable charge")
if(classified.authority.packet.profileId!==profileId) continue
// Historical v6 sealed evidence.validUntil bounded admission; source and
@@ -3,10 +3,11 @@ import { z } from "zod"
import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing"
import type { ChangeEvidence } from "./action-types.js"
import type { McpPrincipal, StoredPlan } from "./store.js"
import { effectiveMcpConnection } from "./store.js"
import { effectiveMcpConnection, getMcpSettings } from "./store.js"
import { decryptSecretExisting, encryptSecret, fingerprint, McpError, privateFingerprinterExisting, privateSecretFingerprint } from "./security.js"
import { routingProfileAuthorityBindingSchema } from "./routing-profile-authority.js"
import type { RoutingSwitchLedgerEntry } from "./routing-switch-ledger-contract.js"
import { findMcpOperation } from "./registry.js"
const fail = (message: string) => new McpError("STATE_CONFLICT", message, 409)
const uuid = z.string().uuid(), proof = z.string().regex(/^[a-f0-9]{64}$/), stamp = z.string().datetime()
@@ -14,7 +15,7 @@ const principal = z.object({ connectionId: uuid, credentialId: uuid, credentialE
export const manualInverseBindingSchema = z.object({ revision: z.literal(1), recoveryOperationId: uuid, recoveryPlanId: uuid,
sourceOperationId: uuid, sourcePlanId: uuid, sourceStepIndex: z.literal(0), profileId: uuid, profileVersion: z.number().int().positive(),
connectionId: uuid, ownerId: z.string().min(1).max(1000), originalPrincipal: principal, recoveryPrincipal: principal,
operationVersion: z.literal(7), sourceVersion: z.literal(7), authorityProof: proof, sourceProof: proof,
operationVersion: z.union([z.literal(7), z.literal(8)]), sourceVersion: z.literal(7), authorityProof: proof, sourceProof: proof,
sourceRequestProof: proof, recoveryRequestProof: proof, sourceEvidenceProof: proof,
recoveryEvidenceProof: proof, recoveryPayloadProof: proof,
scope: z.array(z.number().int().positive().safe()).min(1).max(1000), createdAt: stamp, chargeUnits: z.literal(1) }).strict()
@@ -74,7 +75,8 @@ export async function selectManualInverseAdmissionWithinOwner(db: PoolClient, pl
if (plan.operation_id!=="operations.recover") return undefined
const step=evidence.steps[0]
if (evidence.steps.length!==1 || step.kind!=="service" || step.service!=="routing.community.scoped.recover") return undefined
if (plan.operation_version!==7 || step.input.mode!=="manual") throw fail("Manual recovery action version/mode differs")
if (plan.operation_version!==findMcpOperation(plan.operation_id)?.version) throw new McpError("PLAN_EXPIRED", "Версия операции изменилась; подготовьте новый план", 409)
if (step.input.mode!=="manual") throw fail("Manual recovery mode differs")
const { loadScopedManualRecoveryAdmissionWithinOwner } = await import("./routing-community-scoped-recovery.js")
const loaded = await loadScopedManualRecoveryAdmissionWithinOwner(step.input, actor, db)
const { source } = loaded
@@ -92,7 +94,7 @@ function expectedBinding(selected: NonNullable<Awaited<ReturnType<typeof selectM
return manualInverseBindingSchema.parse({revision:1,recoveryOperationId:operationId,recoveryPlanId:plan.id,
sourceOperationId:selected.source.row.id,sourcePlanId:selected.source.row.source_plan_id,sourceStepIndex:0,profileId:authority.profileId,profileVersion:authority.profileVersion,
connectionId:actor.connectionId,ownerId:authority.ownerId,originalPrincipal:principal.parse(selected.input.principal),recoveryPrincipal:actor,
operationVersion:7,sourceVersion:7,authorityProof:selected.authority.proof,sourceProof:selected.source.sourceProof,
operationVersion:plan.operation_version,sourceVersion:selected.source.row.source_version,authorityProof:selected.authority.proof,sourceProof:selected.source.sourceProof,
sourceRequestProof:selected.input.requestProof,recoveryRequestProof:selected.recoveryInput.requestProof,
sourceEvidenceProof:selected.sourceEvidenceProof,recoveryEvidenceProof:fingerprint(plan.evidence.sealed),
recoveryPayloadProof:fingerprint(plan.payload.sealed),scope:authority.scope,createdAt:observedAt,chargeUnits:1})
@@ -134,7 +136,7 @@ export async function admitManualInverseWithinOwner(db: PoolClient, operationId:
/** A committed charge is required before the existing manual native intent.
* This authenticates identity only; existing live/native authority still runs. */
export async function assertManualInverseChargeWithinOwner(db:PoolClient,operationId:string,input:Record<string,unknown>,actor:McpPrincipal) {
const rows=(await db.query<StoredPlan>("SELECT p.* FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id WHERE o.id=$1 AND o.connection_id=$2 AND o.state='running' AND p.state='executing' AND p.operation_id='operations.recover' AND p.operation_version=7",[operationId,actor.connectionId])).rows
const rows=(await db.query<StoredPlan>("SELECT p.* FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id WHERE o.id=$1 AND o.connection_id=$2 AND o.state='running' AND p.state='executing' AND p.operation_id='operations.recover' AND p.operation_version=$3",[operationId,actor.connectionId,findMcpOperation("operations.recover")?.version])).rows
if(rows.length!==1) throw fail("Manual native claim requires current running action version")
const plan=rows[0], evidence=JSON.parse(await decryptSecretExisting(plan.evidence.sealed)) as ChangeEvidence
const step=evidence.steps[0]
@@ -154,7 +156,7 @@ export async function assertManualInverseChargeWithinOwner(db:PoolClient,operati
export async function authenticateManualInverseHistoryWithinOwner(db:PoolClient,
intent:{revision?:number;manualInverseCharge?:ManualInverseReceipt;operationId:string;sourceOperationId:string;sourceReceiptId:string;sourceProof:string;requestSeal:string;connectionId:string;ownerId:string},
authority:z.infer<typeof routingProfileAuthorityBindingSchema>,observedAt:string,
identity:{sourceEvidenceProof:string;recoveryEvidenceProof:string;recoveryPlanId:string|null;sourcePlanId:string|null;sourceRequestProof:unknown;sourcePrincipal:unknown;recoveryPrincipal:unknown}) {
identity:{sourceEvidenceProof:string;recoveryEvidenceProof:string;recoveryPlanId:string|null;recoveryVersion:number|null;sourcePlanId:string|null;sourceRequestProof:unknown;sourcePrincipal:unknown;recoveryPrincipal:unknown}) {
const rows=(await db.query<ChargeRow>(`SELECT ${columns} FROM mcp_routing_manual_inverse_charges WHERE recovery_operation_id=$1 OR source_operation_id=$2 LIMIT 3`,[intent.operationId,intent.sourceOperationId])).rows
const copy = intent.revision === 2 ? await authenticateCopy(intent.manualInverseCharge, observedAt) : undefined
if (!rows.length && !copy) return false
@@ -166,7 +168,8 @@ export async function authenticateManualInverseHistoryWithinOwner(db:PoolClient,
value.profileId!==authority.packet.profileId || value.profileVersion!==authority.packet.profileVersion || value.authorityProof!==authority.proof ||
value.connectionId!==intent.connectionId || value.ownerId!==intent.ownerId || value.sourceProof!==intent.sourceProof ||
value.recoveryRequestProof!==intent.requestSeal || value.sourceEvidenceProof!==identity.sourceEvidenceProof ||
value.recoveryEvidenceProof!==identity.recoveryEvidenceProof || value.recoveryPlanId!==identity.recoveryPlanId || value.sourcePlanId!==identity.sourcePlanId)throw fail("Historical inverse is not represented by its signed allocation")
value.recoveryEvidenceProof!==identity.recoveryEvidenceProof || value.recoveryPlanId!==identity.recoveryPlanId ||
value.operationVersion!==identity.recoveryVersion || value.sourcePlanId!==identity.sourcePlanId)throw fail("Historical inverse is not represented by its signed allocation")
// Only revision2 needs this extra one-row bounded projection; preserve the
// existing cursor page payload ceiling and legacy protocol untouched.
const payload = copy ? (await db.query<{sealed:string|null}>("SELECT CASE WHEN octet_length(payload::text)<=1048576 THEN payload->>'sealed' END AS sealed FROM mcp_plans WHERE id=$1 LIMIT 2",[identity.recoveryPlanId])).rows : []
@@ -181,10 +184,10 @@ export async function authenticateManualInverseHistoryWithinOwner(db:PoolClient,
/** Existing retention floor, same admission owner, bounded returned batch.
* The version column is only a candidate hint: all private evidence is checked
* before DELETE. Old revision1 remains pinned while its intent survives. */
export async function retainManualInverseChargesWithinOwner(db: PoolClient, retentionDays: number) {
if (!Number.isInteger(retentionDays) || retentionDays < 1 || retentionDays > 365) throw fail("Invalid manual inverse retention")
export async function retainManualInverseChargesWithinOwner(db: PoolClient) {
if ((await db.query("SHOW transaction_isolation")).rows[0]?.transaction_isolation !== "read committed") throw fail("Manual inverse GC requires fresh read-committed owner")
await db.query("SELECT pg_advisory_xact_lock(hashtextextended('mcp-runner-admission',0))")
const { requestsRetentionDays } = await getMcpSettings(db)
const observedAt=iso((await db.query("SELECT date_trunc('milliseconds',clock_timestamp()) AS at")).rows[0].at)
const candidates=(await db.query<{recovery_operation_id:string;source_operation_id:string;profile_version:number}>(`SELECT c.recovery_operation_id,c.source_operation_id,c.profile_version
FROM mcp_routing_manual_inverse_charges c JOIN mcp_scoped_recovery_intents r
@@ -192,7 +195,7 @@ export async function retainManualInverseChargesWithinOwner(db: PoolClient, rete
WHERE r.mode='manual' AND r.authority_profile_id IS NULL AND r.authority_profile_version IS NOT NULL
AND c.created_at < now()-make_interval(days=>$1) AND c.created_at < now()-interval '1 hour'
AND NOT EXISTS(SELECT 1 FROM mcp_operations o WHERE o.id=c.recovery_operation_id AND o.state IN('pending','running'))
ORDER BY c.created_at,c.recovery_operation_id LIMIT 100 FOR UPDATE OF c,r`,[retentionDays])).rows
ORDER BY c.created_at,c.recovery_operation_id LIMIT 100 FOR UPDATE OF c,r`,[requestsRetentionDays])).rows
const {manualInverseHistoryProjection,classifyManualInverseHistory}=await import("./routing-manual-inverse-history.js")
let deleted=0
for(const candidate of candidates) {
@@ -201,11 +204,11 @@ export async function retainManualInverseChargesWithinOwner(db: PoolClient, rete
" WHERE r.mode='manual' AND r.source_operation_id=$1 AND r.source_step_index=0 AND r.recovery_operation_id=$2",[candidate.source_operation_id,candidate.recovery_operation_id])).rows
if(rows.length!==1)throw fail("Retention intent/source pair disappeared")
const row=rows[0],classified=await classifyManualInverseHistory(row,observedAt)
if(classified.kind!=="persistent" || classified.intent.revision!==2 || row.recovery_version!==7 ||
if(classified.kind!=="persistent" || classified.intent.revision!==2 || row.recovery_version!==classified.intent.manualInverseCharge.binding.operationVersion ||
row.authority_profile_version!==candidate.profile_version || row.authority_profile_version!==classified.intent.manualInverseCharge.binding.profileVersion)
throw fail("Retention hint is not an authenticated persistent intent2")
if(!await authenticateManualInverseHistoryWithinOwner(db,classified.intent,classified.authority,observedAt,{
sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),recoveryPlanId:row.recovery_plan_id,sourcePlanId:row.source_plan_id,sourceRequestProof:classified.sourceRequestProof,sourcePrincipal:classified.sourcePrincipal,recoveryPrincipal:classified.recoveryPrincipal}))
sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),recoveryPlanId:row.recovery_plan_id,recoveryVersion:row.recovery_version,sourcePlanId:row.source_plan_id,sourceRequestProof:classified.sourceRequestProof,sourcePrincipal:classified.sourcePrincipal,recoveryPrincipal:classified.recoveryPrincipal}))
throw fail("Retention parent is not authenticated")
const result=await db.query("DELETE FROM mcp_routing_manual_inverse_charges WHERE recovery_operation_id=$1 AND source_operation_id=$2",[candidate.recovery_operation_id,candidate.source_operation_id])
if(result.rowCount!==1)throw fail("Retention parent delete was not confirmed")
+21 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { routeAiProfileSchema, type RouteObservation } from "@mmapp/contracts/mcp-routing"
import { rankRoutingCandidates, decideRoutingSwitch, type ControllerState, type PathHealth } from "./routing-model.js"
import { rankRoutingCandidates, decideRoutingSwitch, selectFailoverReserveProbes, type ControllerState, type PathHealth } from "./routing-model.js"
const now = Date.parse("2026-10-17T10:00:00Z"), at = new Date(now).toISOString()
const profile = routeAiProfileSchema.parse({ name: "Test", serverId: 1, community: "65000:10", family: "ipv4", candidates: [
@@ -81,6 +81,26 @@ test("failover starts a fresh series after downtime or legacy state and ignores
assert.equal(result.state.failureObservedAt, new Date(time).toISOString())
}
})
test("bounded failover probing keeps the highest eligible reserve and eventually visits every other eligible probe", () => {
const controlProbe = { targetInterface: "exit-a", expectedHops: [{ serverId: 3, interface: "exit-a" }] }
const configured = routeAiProfileSchema.parse({ ...profile, excludedCandidateIds: ["05"],
candidates: Array.from({ length: 100 }, (_, index) => ({ ...profile.candidates[0], id: String(index).padStart(2, "0"),
controlProbe: index === 4 ? undefined : controlProbe })) })
const ranking = rankRoutingCandidates(configured, a, now)
const expected = ranking.filter(row => row.eligible && row.candidate.controlProbe && !["00", "01"].includes(row.candidate.id)).map(row => row.candidate.id)
let cursor: string | undefined
for (const alternate of expected) {
const probes = selectFailoverReserveProbes(ranking, "00", cursor)
assert.deepEqual(probes.candidateIds, ["01", alternate])
assert.equal(probes.cursor, alternate)
cursor = probes.cursor
}
assert.deepEqual(selectFailoverReserveProbes(ranking, "00", cursor).candidateIds, ["01", "02"])
assert.deepEqual(selectFailoverReserveProbes(ranking, "00", "05").candidateIds, ["01", "02"])
const pinned = rankRoutingCandidates({ ...configured, pinnedCandidateId: "09" }, a, now)
assert.deepEqual(selectFailoverReserveProbes(pinned, "00"), { candidateIds: ["09"], cursor: undefined })
assert.deepEqual(selectFailoverReserveProbes(pinned, "09"), { candidateIds: [], cursor: undefined })
})
test("routing combines RTT, independent losses and bottleneck speed, never claims an observed path", () => {
const ranked = rankRoutingCandidates(profile, a, now).find(row => row.candidate.id === "a")!
assert.equal(ranked.pingMs, 20); assert.ok(Math.abs(ranked.lossPct! - 1.99) < 0.00001)
+11
View File
@@ -52,6 +52,17 @@ export function rankRoutingCandidates(profile: RouteAiProfile, observations: Rou
export interface PathHealth { id: string; candidateId: string; status: "passed" | "failed" | "unknown"; observedAt: string }
export interface ControllerState { pendingCandidateId?: string; consecutive?: number; observationKey?: string; lastSwitchAt?: string; switches?: string[]; operationId?: string; pausedReason?: string; failureCount?: number; failureKey?: string; failureObservedAt?: string; failingCandidateId?: string }
export function selectFailoverReserveProbes(ranking: ReturnType<typeof rankRoutingCandidates>, currentCandidateId: string, previous?: string) {
const standbys = ranking.filter(({ candidate, eligible }) => eligible && candidate.id !== currentCandidateId && candidate.controlProbe)
.map(({ candidate }) => candidate.id)
const [preferred, ...remaining] = standbys
if (remaining.length === 0) return { candidateIds: standbys, cursor: undefined }
const index = remaining.indexOf(previous ?? "")
const cursor = remaining[(index + 1) % remaining.length]
return { candidateIds: [preferred, cursor], cursor }
}
const pathHealthFreshnessSec = 90
export function decideRoutingSwitch(profile: RouteAiProfile, state: ControllerState, ranking: ReturnType<typeof rankRoutingCandidates>, currentCandidateId: string | null, now = Date.now(), health: PathHealth[] = [], chargedSwitchesPerHour?: number) {
const next: ControllerState = { ...state, switches: (state.switches ?? []).filter(at => now - Date.parse(at) < 3600000) }
@@ -0,0 +1,28 @@
import { isIP } from "node:net"
export function parseRoutingEndpoint(value: unknown) {
if (typeof value !== "string" || !value.length || value.length > 256) throw Error("missing-or-unsupported-field")
const match = /^([^%@/,\s]+)(?:\/(\d+))?(?:%([^%@/,\s]+))?(?:@([^%@/,\s]+))?$/.exec(value)
if (!match) throw Error("unsupported-endpoint")
const [, address, prefix, interfaceName, vrf] = match
const family = isIP(address)
if (!family) throw Error("unsupported-address")
if (prefix !== undefined && prefix !== String(family === 4 ? 32 : 128)) throw Error("unsupported-peer-range")
return { address, interface: interfaceName, vrf }
}
export function matchesBgpSessionContext(
row: Record<string, unknown>,
expected: { table: string; vrf: string },
local: ReturnType<typeof parseRoutingEndpoint>,
remote: ReturnType<typeof parseRoutingEndpoint>,
) {
if (!Object.hasOwn(row, "routing-table") || row["routing-table"] !== expected.table) return false
if (Object.hasOwn(row, "vrf")) {
return row.vrf === expected.vrf && (local.vrf === undefined || local.vrf === expected.vrf) &&
(remote.vrf === undefined || remote.vrf === expected.vrf)
}
// RouterOS may report the transport VRF only in the two runtime addresses.
// The installation table alone cannot identify it.
return local.vrf === expected.vrf && remote.vrf === expected.vrf
}
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto"
import { test } from "node:test"
import { z } from "zod"
import ts from "typescript"
import { scopedTargetRuleMatches } from "./routing-community-scoped-model.js"
/** Inert dependency-boundary tests only. SQL/HTTP acceptance must create the
* real checkpoint through the existing manual service and production HMAC;
@@ -36,6 +37,10 @@ async function fixture() {
const checkpoint = { checkpointProof: "persisted-checkpoint", observedAt: new ClockDate(now - 500).toISOString(), packet }
const sharedToken = Object.freeze({}), acquisition = Object.freeze({}), sharedBinding = { sourceOperationId: row.id, profile: structuredClone(profile), principal: structuredClone(principal),
deadline: now + 10000, contextSources: structuredClone(input.contextSources), candidateId: "b" }
let substitutedTarget: string | undefined
const ownedTarget = () => ({ nativeId, rawRow: { rule: nativeBody },
matchesRuleProof: (body: string, proof: unknown) => scopedTargetRuleMatches({ rawRow: { rule: nativeBody },
desiredTargetProof: observed => observed === nativeBody ? nativeTarget : observed === packet.desiredRule ? substitutedTarget ?? "" : "" }, body, proof) })
const providers: Record<string, unknown> = {
"./routing-network-execution-session.js": { isVerifiedManualEffectOwner: (owner: unknown) => owner === sharedToken && manualOwner },
zod: { z }, "@mmapp/contracts/mcp-routing": { routeAiProfileSchema: { parse: (value: unknown) => value } },
@@ -48,7 +53,7 @@ async function fixture() {
sharedReads++; nativeReads++; if (revoked) throw Error("revoked")
return { before: { scopedExecutionAvailable: !guard, scopedExecutionReason: guard ? "ROUTE_RECOVERY_EXECUTOR_UNAVAILABLE" : null,
dependencyProof: nativeDependency, targetRuleProof: nativeTarget, currentCandidateId: nativeCandidate, sourceProofs: input.contextSources },
ownedTarget: { nativeId, rawRow: { rule: nativeBody } }, fence: async () => { if (revoked || sharedClosed) throw Error("revoked") } }
ownedTarget: ownedTarget(), fence: async () => { if (revoked || sharedClosed) throw Error("revoked") } }
} },
"./routing-network-native-session.js": { inspectRoutingNetworkSharedContext: (token: unknown) => { if (token !== sharedToken || sharedClosed) throw Error("Unknown shared context"); return sharedBinding },
assertRoutingNetworkSharedContextReadOwner: (owner: unknown, read: unknown) => { if (owner !== sharedToken || read !== acquisition || sharedClosed) throw Error("Unknown acquisition owner") },
@@ -67,13 +72,13 @@ async function fixture() {
nativeReads++; sharedReads++; afterNative?.(); afterNative = undefined
return { before: { scopedExecutionAvailable: !guard, scopedExecutionReason: null,
dependencyProof: nativeDependency, targetRuleProof: nativeTarget, currentCandidateId: nativeCandidate, sourceProofs: input.contextSources },
ownedTarget: { nativeId, rawRow: { rule: nativeBody } }, fence: async () => { if (revoked || sharedClosed) throw Error("revoked") } }
ownedTarget: ownedTarget(), fence: async () => { if (revoked || sharedClosed) throw Error("revoked") } }
}, readRouteCommunityContext: async (_profile: unknown, options: { authorize(): Promise<void>; signal: AbortSignal; expectedSources: unknown }) => {
assert.equal(options.signal.aborted, false); assert.deepEqual(options.expectedSources, input.contextSources)
await options.authorize(); nativeReads++; afterNative?.(); afterNative = undefined
return { before: { scopedExecutionAvailable: !guard, scopedExecutionReason: guard ? "ROUTE_RECOVERY_EXECUTOR_UNAVAILABLE" : null,
dependencyProof: nativeDependency, targetRuleProof: nativeTarget, currentCandidateId: nativeCandidate, sourceProofs: input.contextSources },
ownedTarget: { nativeId, rawRow: { rule: nativeBody } }, fence: options.authorize }
ownedTarget: ownedTarget(), fence: options.authorize }
} },
}
const source = await readFile(new URL("./routing-network-configuration-bridge.ts", import.meta.url), "utf8")
@@ -98,6 +103,12 @@ async function fixture() {
stats: () => ({ nativeReads, profileChecks, sourceReads, configChecks, sharedReads, sharedClosed }), advance: (ms: number) => { now += ms },
revoke: () => { revoked = true }, sourceChanged: () => { sourceChanged = true }, checkpointMissing: () => { checkpointMissing = true },
checkpointChanged: () => { checkpointChanged = true }, guard: () => { guard = true },
bareNativeReadback: (proof: string) => {
packet.desiredRule = 'if (bgp-communities includes 65000:10) {\n set gw 192.0.2.9;\n set gw-interface "marker-gre-b";\n accept;\n}'
nativeBody = packet.desiredRule.replace('"marker-gre-b"', "marker-gre-b")
nativeTarget = "bare-target"
substitutedTarget = proof
},
drift: (field: string) => { if (field === "body") nativeBody = "other"; if (field === "dependency") nativeDependency = "other"; if (field === "target") nativeTarget = "other"; if (field === "candidate") nativeCandidate = "a"; if (field === "nativeId") nativeId = "*2"; if (field === "generation") generation = "changed" },
afterNative: (hook: () => void) => { afterNative = hook }, afterCheckpoint: (hook: () => void) => { afterCheckpoint = hook }, afterProfile: (hook: () => void) => { afterProfile = hook } }
}
@@ -111,6 +122,15 @@ test("fixed resolver captures opaque context and consume rechecks configuration
assert.equal(f.stats().nativeReads, 2); assert(f.stats().profileChecks > 5)
await assert.rejects(f.consume(token), /Unknown or consumed/); await assert.rejects(f.resolve(), /already issued/)
})
test("optional RouterOS quotes do not change the sealed checkpoint identity", async () => {
const f = await fixture()
f.bareNativeReadback("desired-target")
const observed = await f.consume(await f.resolve())
assert.equal(observed.configuration.bodyIdentity, JSON.stringify(f.packet.desiredRule))
const drift = await fixture()
drift.bareNativeReadback("different-native-metadata")
await assert.rejects(drift.resolve(), /drifted/)
})
for (const mode of ["positive", "unbranded", "wrong-checkpoint", "terminal-source", "standalone"] as const) {
test(`manual revision-2 round ${mode}`, async () => {
@@ -188,8 +188,9 @@ async function resolveConfigurationContext(sourceOperationId: string, action: Ac
selectedCandidateId: String(input.candidateId), authorize, signal, expectedSources: input.contextSources as Array<{ serverId: number; proof: string }> }))
if (!context.before.scopedExecutionAvailable || !context.ownedTarget) throw fail(String(context.before.scopedExecutionReason ?? "Owned context unavailable"))
const owned = context.ownedTarget
if (owned.nativeId !== original.packet.nativeId || owned.nativeId !== input.nativeId || owned.rawRow.rule !== original.packet.desiredRule ||
context.before.dependencyProof !== original.packet.dependencyProof || context.before.targetRuleProof !== original.packet.desiredTargetProof ||
if (owned.nativeId !== original.packet.nativeId || owned.nativeId !== input.nativeId ||
context.before.dependencyProof !== original.packet.dependencyProof ||
!owned.matchesRuleProof(original.packet.desiredRule, original.packet.desiredTargetProof) ||
context.before.currentCandidateId !== original.packet.desiredCandidateId || fingerprint(context.before.sourceProofs) !== fingerprint(original.packet.contextSources)) throw fail("Current owned rule or dependencies drifted from CONFIG_APPLIED")
await guarded(() => context.fence())
const after = await checkpoint()
@@ -16,6 +16,8 @@ assert(process.env.MCP_STATE_DIR && isAbsolute(process.env.MCP_STATE_DIR))
assert.equal(basename(resolve(process.env.MCP_STATE_DIR)), "state")
assert(basename(dirname(resolve(process.env.MCP_STATE_DIR))).startsWith("mm-mcp-test-"))
const originalEffects = process.env.MMAPP_OWN_ROUTING_ORIGINAL_EFFECTS === "true"
const controllerFailoverOnly = process.env.MMAPP_OWN_ROUTING_CONTROLLER_FAILOVER_ONLY === "true"
assert(!controllerFailoverOnly || originalEffects, "Focused controller failover requires the owned original-effects fixture")
assert(!(originalEffects && process.env.MMAPP_OWN_ROUTING_WORKER_REPRO === "true"), "Owned effect acceptance cannot mix with the worker repro profile")
process.env.AUTH_REQUIRED = "true"
process.env.AUTH_JWT_SECRET = "owned-routing-forward-rounds-acceptance-secret"
@@ -35,7 +37,7 @@ try {
console.log("Worker fixture repro:",await (await import("./routing-worker-fixture-isolation.sql-helper.js")).acceptRoutingWorkerFixtureIsolation(actor))
}
const result = await (await import("./routing-community-scoped-acceptance.js")).acceptScopedRouteCommunitySwitch(actor,
originalEffects ? { originalEffectsOnly: true } : process.env.MMAPP_OWN_ROUTING_WORKER_REPRO==="true"?{}:{ forwardRoundsOnly: true })
originalEffects ? { originalEffectsOnly: true, controllerFailoverOnly } : process.env.MMAPP_OWN_ROUTING_WORKER_REPRO==="true"?{}:{ forwardRoundsOnly: true })
assert.equal(result.accepted, true)
} finally {
await (await import("../../services/scheduler.js")).stopSchedulerForDatabaseOperation()
@@ -154,6 +154,25 @@ for (const mutation of ["duplicate-config", "wrong-config-id", "unresolved-templ
assert.equal(f.parser.parseBgp("peer1", raw).status, "unknown")
})
for (const ipv6 of [false, true]) test("BGP runtime context can come from both exact qualified " + (ipv6 ? "IPv6" : "IPv4") + " endpoints", () => {
const f = fixture(ipv6), raw = structuredClone(f.bgp) as unknown as RawMutation, session = raw.payload[0]
const local = session["local.address"], remote = session["remote.address"]
delete session.vrf
session["local.address"] = `${local}@main`
session["remote.address"] = `${remote}@main`
const observation = f.parser.parseBgp("peer1", raw)
assert.equal(observation.status, "observed", JSON.stringify(observation))
assert.equal(observation.securityProof, false)
for (const mutation of [
{ "remote.address": remote }, { "remote.address": `${remote}@tenant` },
{ "remote.address": `${remote}@main@main` }, { "routing-table": "main" },
{ vrf: "tenant" }, { vrf: null },
]) {
assert.equal(f.parser.parseBgp("peer1", { ...raw, payload: [{ ...session, ...mutation }] }).status, "unknown")
}
assert.equal(f.parser.parseBgp("peer1", { ...raw, payload: [session, { ...session, ".id": "*B3" }] }).status, "unknown")
})
test("native cached non-established BGP cannot invent a model FSM state from an undocumented field", () => {
const f = fixture(), raw = structuredClone(f.bgp) as unknown as RawMutation
raw.payload[0].established = "false"
@@ -2,6 +2,7 @@ import { isIP } from "node:net"
import { z } from "zod"
import { canonicalBgpPolicyPrefix } from "../../services/bgp-community-policy-model.js"
import { captureRoutingNetworkManifest, type RoutingNetworkRound } from "./routing-network-verifier-model.js"
import { matchesBgpSessionContext, parseRoutingEndpoint } from "./routing-native-endpoint.js"
/** Pure decoded-response parsers, NOT source authentication or completeness
* proof. Only the explicit RouterOS7.20–7.26 field subset below is recognized.
@@ -32,11 +33,8 @@ function canonicalAddress(value: unknown): string {
return canonicalBgpPolicyPrefix(text + "/" + (family === 4 ? 32 : 128)).split("/")[0]
}
function endpoint(value: unknown) {
const text = literal(value), match = /^([^%@/,\s]+)(?:\/(\d+))?(?:%([^%@/,\s]+))?(?:@([^%@/,\s]+))?$/.exec(text)
if (!match) return fail("unsupported-endpoint")
const address = canonicalAddress(match[1])
if (match[2] !== undefined && match[2] !== String(isIP(address) === 4 ? 32 : 128)) return fail("unsupported-peer-range")
return { address, interface: match[3], vrf: match[4] }
const parsed = parseRoutingEndpoint(value)
return { ...parsed, address: canonicalAddress(parsed.address) }
}
function decodedRows(payload: unknown, maximum = 1000, ids = true): Row[] {
let nodes = 0
@@ -144,7 +142,7 @@ export function captureRoutingNetworkNativeParser(rawManifest: unknown) {
if (instance !== wanted.instance) continue
if (local.address !== wanted.localAddress || remote.address !== wanted.remoteAddress) continue
if (integer(row["remote.as"], 4294967295) !== wanted.remoteAsn || !afi(row["remote.afi"]).includes(expectedAfi) || !afi(row["local.afi"]).includes(expectedAfi)) return unknown("bgp-session-asn-or-afi-drift")
if (row.vrf !== binding.vrf || row["routing-table"] !== binding.table || [local.vrf, remote.vrf].some(vrf => vrf !== undefined && vrf !== binding.vrf) || local.interface !== binding.local.interface || remote.interface !== binding.remote.interface) return unknown("bgp-session-context-drift")
if (!matchesBgpSessionContext(row, binding, local, remote) || local.interface !== binding.local.interface || remote.interface !== binding.remote.interface) return unknown("bgp-session-context-drift")
relevant.push(row)
}
if (relevant.length !== 1) return unknown("missing-or-ambiguous-bgp-session")
@@ -15,7 +15,7 @@ import { createRoutingSourceContext, readRoutingEndpoints } from "./routing-obse
import { routeContextClients } from "./routing-community-context-native.js"
import { resolveRoutingNetworkDestinationPins } from "./routing-network-native-destination.js"
import { readMeasurementAddress } from "./routing-measurement-address.js"
import { readRouteCommunityContextWithSharedNativeSession } from "./routing-community-context.js"
import { readRouteCommunityContextWithSharedNativeSession, type OwnedRouteCommunityTarget } from "./routing-community-context.js"
import type { ActionContext, ChangeEvidence } from "./action-types.js"
import type { RoutingNetworkForwardRoundConfigurationContext } from "./routing-network-configuration-bridge.js"
@@ -45,7 +45,7 @@ type SharedBinding = Readonly<{ sourceOperationId: string; profile: z.infer<type
deadline: number; contextSources: Array<{ serverId: number; proof: string }>; candidateId: string; signal: AbortSignal }>
type SharedContextResult = { before: { scopedExecutionAvailable: boolean; scopedExecutionReason: string | null; dependencyProof: string; targetRuleProof: string | null;
currentCandidateId: string | null; sourceProofs: Array<{ serverId: number; proof: string }> }; fence(): Promise<void>;
ownedTarget: { nativeId: string; rawRow: Row } | undefined }
ownedTarget: Pick<OwnedRouteCommunityTarget, "nativeId" | "rawRow" | "matchesRuleProof"> | undefined }
type SharedOwner = { binding: SharedBinding; active(): void; fence(): Promise<void>; close(): void;
read(serverId: number, path: string, body?: Record<string, string>): Promise<unknown>; run(): Promise<SharedContextResult> }
const sharedContexts = new WeakMap<RoutingNetworkSharedContext, SharedOwner>()
@@ -47,7 +47,7 @@ const collections = (path: string): Record<string, unknown>[] => {
if (path === "/ip/vrf") return [{ ".id": "*v1", name: "probe", interfaces: "wan1", disabled: "false" }, { ".id": "*v2", name: "main", interfaces: "all", disabled: "false" }]
if (path === "/routing/route") return [{ ".id": "*r1", afi: "ip", "dst-address": "203.0.113.0/24", "routing-table": "tenant", gateway: "192.0.2.1@main", "immediate-gw": "192.0.2.1%wan1", active: "true", dynamic: "true", contribution: "active", bgp: "true", "bgp.session": "*s1" }]
if (path === "/routing/bgp/connection") return [{ ".id": "*c1", name: "peer1", instance: "default", "remote.address": "192.0.2.1", "local.address": "192.0.2.2", "remote.as": "65001", afi: "ip", templates: "", vrf: "main", "routing-table": "tenant", disabled: "false" }]
if (path === "/routing/bgp/session") return [{ ".id": "*s1", name: "peer1-1", instance: "default", "remote.address": "192.0.2.1", "local.address": "192.0.2.2", "remote.as": "65001", "local.afi": "ip", "remote.afi": "ip", established: "true", vrf: "main", "routing-table": "tenant" }]
if (path === "/routing/bgp/session") return [{ ".id": "*s1", name: "peer1-1", instance: "default", "remote.address": "192.0.2.1@main", "local.address": "192.0.2.2@main", "remote.as": "65001", "local.afi": "ip", "remote.afi": "ip", established: "true", "routing-table": "tenant" }]
if (path === "/routing/bfd/session") return [{ ".id": "*b1", "local-address": "192.0.2.2", "remote-address": "192.0.2.1", interface: "wan1", vrf: "main", state: "up" }]
if (path === "/log") return []
if (path === "/routing/table") return ["main", "tenant", "probe"].map((name, i) => ({ ".id": "*t" + i, name, fib: "true" }))
@@ -16,6 +16,7 @@ import { canonicalVrfMembers } from "../../services/vrf-members.js"
import { readOspfEffectiveDisabled } from "../../services/ospf-effective-disabled.js"
import { isConfirmedRoutingFibTable } from "../../services/routing-table-fib.js"
import * as contextModel from "./routing-community-context-model.js"
import { scopedTargetRuleMatches } from "./routing-community-scoped-model.js"
import { prepareOriginalEffectSelection } from "./routing-original-effects.js"
import { readMeasurementAddress } from "./routing-measurement-address.js"
import { routeContextClients } from "./routing-community-context-native.js"
@@ -95,7 +96,8 @@ async function fixture(autoRecovery = false) {
await loadSource("./routing-community-context.ts", { ...common, "./routing-network-native-session.js": native,
"../../services/routing-table-fib.js": { isConfirmedRoutingFibTable }, "./routing-original-effects.js": { prepareOriginalEffectSelection },
"../../services/bgp-export-model.js": { nativeBgpRows, bgpTemplateReferences }, "../../services/bgp-filter-binding-model.js": { bgpFilterName, effectiveBgpFilter, stableBgpPolicyRow },
"./routing-community-context-model.js": contextModel, "./path-proof-purpose.js": { pathProofTag },
"./routing-community-context-model.js": contextModel, "./routing-community-scoped-model.js": { scopedTargetRuleMatches },
"./path-proof-purpose.js": { pathProofTag },
"./management-transport.js": { assertManagementTransport: async () => { throw Error("No alternate management fixture") } } }, context)
await loadSource("./routing-network-native-session.ts", { ...common, "node:net": { isIP }, zod: { z }, "@mmapp/contracts/mcp-routing": { routeAiProfileSchema },
"./routing-community-context.js": context, "./registry.js": { findMcpOperation: () => ({ version: 7 }) },
@@ -3,6 +3,9 @@ import type { ChangeEvidence } from "./action-types.js"
import { fingerprint, McpError } from "./security.js"
type Checks = NonNullable<ChangeEvidence["observationChecks"]>
export function latestRoutingMetric(rows: readonly RouteObservation[], metric: "pingAt" | "bandwidthAt") {
return rows.find(row => row[metric] !== null || row.sourceVerified === false)
}
export function assertRoutingChecksFresh(checks: Checks, now = Date.now()) {
if (checks.length > 1000 || checks.some(check => !Number.isFinite(Date.parse(check.validUntil)) || Date.parse(check.validUntil) <= now)) throw new McpError("MEASUREMENT_UNKNOWN", "Измерения маршрутизации устарели; нужен новый план", 409)
}
@@ -2,7 +2,7 @@ import type { RouteObservation } from "@mmapp/contracts/mcp-routing"
import { pool } from "../../db/index.js"
import { McpError } from "./security.js"
import type { ChangeEvidence } from "./action-types.js"
import { assertRoutingChecksFresh, assertRoutingObservationValues } from "./routing-observations-model.js"
import { assertRoutingChecksFresh, assertRoutingObservationValues, latestRoutingMetric } from "./routing-observations-model.js"
import { createRoutingSourceContext, readRoutingEndpoints, type RoutingSourceRecord } from "./routing-observation-source.js"
export const routingObservationColumns = `o.id,o.src_server_id AS "srcServerId",o.dst_server_id AS "dstServerId",o.src_interface AS "srcInterface",o.dst_interface AS "dstInterface",o.family,o.routing_table AS "table",o.ping_ms AS "pingMs",o.loss_pct AS "lossPct",o.dl_mbps AS "dlMbps",o.ul_mbps AS "ulMbps",to_char(o.ping_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS "pingAt",to_char(o.bandwidth_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS "bandwidthAt",o.context_verified AS "contextVerified",o.source,to_char(o.created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS "createdAt",o.source_proof AS "sourceProof"`
@@ -23,16 +23,67 @@ export async function readRoutingObservations(serverIds: number[], serverId?: nu
return observations((await pool.query(`SELECT ${routingObservationColumns} FROM mcp_routing_observations o WHERE o.src_server_id=ANY($1::bigint[]) AND o.dst_server_id=ANY($1::bigint[]) AND ($2::bigint IS NULL OR o.src_server_id=$2 OR o.dst_server_id=$2) AND o.created_at>now()-interval '24 hours' ORDER BY o.created_at DESC,o.id DESC LIMIT 10000`, [serverIds, serverId ?? null])).rows, serverIds)
}
interface RoutingObservationQuery {
serverId?: number
srcServerId?: number
dstServerId?: number
srcInterface?: string
dstInterface?: string
family?: "ipv4" | "ipv6"
table?: string
limit: number
cursor?: { recordedAt: string; id: string }
}
export async function queryRoutingObservations(serverIds: number[], input: RoutingObservationQuery) {
if ([input.serverId, input.srcServerId, input.dstServerId].some(id => id !== undefined && !serverIds.includes(id))) {
throw new McpError("SERVER_DENIED", "История измерений вне разрешённой области", 403)
}
const { rows } = await pool.query(`SELECT ${routingObservationColumns} FROM mcp_routing_observations o
WHERE o.src_server_id=ANY($1::bigint[]) AND o.dst_server_id=ANY($1::bigint[])
AND ($2::bigint IS NULL OR o.src_server_id=$2 OR o.dst_server_id=$2)
AND ($3::bigint IS NULL OR o.src_server_id=$3) AND ($4::bigint IS NULL OR o.dst_server_id=$4)
AND ($5::text IS NULL OR o.src_interface=$5) AND ($6::text IS NULL OR o.dst_interface=$6)
AND ($7::text IS NULL OR o.family=$7) AND ($8::text IS NULL OR o.routing_table=$8)
AND ($9::timestamptz IS NULL OR (o.created_at,o.id)<($9::timestamptz,$10::uuid))
AND o.created_at>now()-interval '24 hours'
ORDER BY o.created_at DESC,o.id DESC LIMIT $11`, [serverIds, input.serverId ?? null, input.srcServerId ?? null,
input.dstServerId ?? null, input.srcInterface ?? null, input.dstInterface ?? null, input.family ?? null, input.table ?? null,
input.cursor?.recordedAt ?? null, input.cursor?.id ?? null, input.limit + 1])
const page = await observations(rows.slice(0, input.limit), serverIds)
const last = page.at(-1)
return { observations: page, nextCursor: rows.length > input.limit && last?.recordedAt ? { recordedAt: last.recordedAt, id: last.id } : null }
}
/** Match the sealed observation to the newest record for the exact context.
* No re-ranking and no replacement by an older healthy observation. */
export async function verifyRoutingObservationChecks(checks: ChangeEvidence["observationChecks"]) {
if (!checks?.length) return
assertRoutingChecksFresh(checks)
const { rows } = await pool.query(`SELECT latest.* FROM jsonb_array_elements($1::jsonb) WITH ORDINALITY AS q(value,ordinality)
const { rows } = await pool.query(`SELECT q.ordinality AS "checkOrdinal", latest.* FROM jsonb_array_elements($1::jsonb) WITH ORDINALITY AS q(value,ordinality)
LEFT JOIN LATERAL(SELECT ${routingObservationColumns} FROM mcp_routing_observations o
WHERE o.src_server_id=(q.value->>'srcServerId')::bigint AND o.dst_server_id=(q.value->>'dstServerId')::bigint
AND o.src_interface=q.value->>'srcInterface' AND o.family=q.value->>'family' AND o.routing_table=q.value->>'table'
AND o.created_at>now()-interval '24 hours' ORDER BY o.created_at DESC,o.id DESC LIMIT 1) latest ON true ORDER BY q.ordinality`, [JSON.stringify(checks)])
const latest = await observations(rows, [...new Set(checks.flatMap(check => [check.srcServerId, check.dstServerId]))])
AND (q.value->>'dstInterface' IS NULL OR o.dst_interface=q.value->>'dstInterface')
AND o.created_at>now()-interval '24 hours'
AND (q.value->>'metric' IS NULL OR (o.created_at,o.id)>=(SELECT sealed.created_at,sealed.id FROM mcp_routing_observations sealed WHERE sealed.id=(q.value->>'id')::uuid))
ORDER BY o.created_at DESC,o.id DESC LIMIT (CASE WHEN q.value->>'metric' IS NULL THEN 1 ELSE 10001 END)) latest ON true
ORDER BY q.ordinality,latest."createdAt" DESC,latest.id DESC LIMIT 10001`, [JSON.stringify(checks)])
if (rows.length > 10000) throw new McpError("MEASUREMENT_UNKNOWN", "Слишком много новых измерений маршрутизации; нужен новый план", 409)
const verified = await observations(rows.filter(row => row.id !== null).map(({ checkOrdinal: _, ...row }) => row), [...new Set(checks.flatMap(check => [check.srcServerId, check.dstServerId]))])
const groups = new Map<number, RouteObservation[]>()
let index = 0
for (const row of rows) {
if (row.id === null) continue
const ordinal = Number(row.checkOrdinal)
const group = groups.get(ordinal) ?? []
group.push(verified[index++])
groups.set(ordinal, group)
}
const latest = checks.flatMap((check, position) => {
const candidates = groups.get(position + 1) ?? []
const match = check.metric ? latestRoutingMetric(candidates, check.metric) : candidates[0]
return match ? [match] : []
})
assertRoutingObservationValues(checks, latest)
}
@@ -5,27 +5,58 @@ import { randomUUID } from 'node:crypto';
import test from 'node:test';
const require=createRequire(new URL('../../../package.json',import.meta.url)),ts=require('typescript'),{z}=require('zod');
const code=ts.transpileModule(await readFile(new URL('./routing-community-scoped-service.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
async function fixture(mode='prepared',failure){
async function loadModel(path, resolveDependency = require) {
const source = await readFile(new URL(path, import.meta.url), 'utf8');
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText;
const exports = {};
new Function('exports', 'require', code)(exports, resolveDependency);
return exports;
}
const filterBody = await loadModel('../../services/routing-filter-body.ts');
const pathProofPurpose = await loadModel('./path-proof-purpose.ts');
const nativeEndpoint = await loadModel('./routing-native-endpoint.ts');
const { originalEffectReportSchema } = await loadModel('./routing-original-effects.ts', name => {
if (name === './routing-community-context-model.js' || name === './security.js') return {};
if (name === './routing-native-endpoint.js') return nativeEndpoint;
return require(name);
});
class FixtureMcpError extends Error {
constructor(code, message) {
super(message);
this.code = code;
}
}
async function fixture(mode='prepared',failure,{ proofRevision = 3, manualEffectPolicy } = {}){
const calls=[],principal={connectionId:randomUUID(),credentialId:randomUUID(),credentialExpiresAt:new Date(Date.now()+120000).toISOString()},connection={id:principal.connectionId,owner_id:'owner',generation:'generation',owner_permissions:['mm:filters:write','mm:network:read'],policy:{mode:'copilot',permissions:['mm:filters:write','mm:network:read'],scope:{serverIds:[1]},expiresAt:new Date(Date.now()+120000).toISOString()},owner_valid_until:new Date(Date.now()+120000)};
const profile={serverId:1,family:'ipv4',table:'tenant',probeVrf:'probe',community:'65000:10',autoRecovery:false,candidates:[{id:'a',jumpHostId:1,exitNodeId:1,gateway:'192.0.2.8',gatewayRef:{resource:'ipv4_route',id:'*r0'},controlProbe:{}},{id:'b',jumpHostId:1,exitNodeId:1,gateway:'192.0.2.9',gatewayRef:{resource:'ipv4_route',id:'*r'},controlProbe:{}}],excludedCandidateIds:[],pinnedCandidateId:null};
const input={serverId:1,profile,candidateId:'b',principal,ownerId:'owner',policyProof:JSON.stringify(connection.policy),connectionGeneration:'generation',generation:'access',proofRevision:3,originalCandidateId:'a',nativeId:'*1',outIface:'',originalRuleCiphertext:'original',desiredRuleCiphertext:'desired',desiredTargetProof:'desired-proof',beforeRules:[],receiptId:randomUUID()};
const input={serverId:1,profile,candidateId:'b',principal,ownerId:'owner',policyProof:JSON.stringify(connection.policy),connectionGeneration:'generation',generation:'access',proofRevision,originalCandidateId:'a',nativeId:'*1',outIface:'',originalRuleCiphertext:'original',desiredRuleCiphertext:'desired',desiredTargetProof:'desired-proof',beforeRules:[],receiptId:randomUUID()};
if (manualEffectPolicy) input.manualEffectPolicy = manualEffectPolicy;
if(mode!=='legacy')input.originalEffectSelection=mode==='prepared'?{status:'prepared',probe:{key:'b',serverId:1,destination:'198.51.100.20',source:'198.51.100.10',interface:'wan',probeVrf:'probe'}}:{status:'unknown',reason:'unsupported-source'};
let wrote=false,handler,network,foreign=false,reads=0,rounds=0,closed=0,ownerClosed=false,nativeCalls=100;
const unknown=reason=>({scope:'prepared-route-fib-adjacency-control-effects',status:'unknown',reason,rounds:[],logCoverage:'unknown',semanticProof:false,forwardingVerified:false,autoRecoveryAllowed:false});
const report={...unknown(null),status:'observed-expected',rounds:[{controlProbe:'passed'},{controlProbe:'passed'}]};
const session={sharedContext:{},readSnapshot:async stage=>{calls.push('baseline:'+stage);return {};},readConfigurationBoundForwardSnapshot:async()=>{calls.push('round:'+(++rounds));if(failure==='round'||failure==='round-closed'){ownerClosed=failure==='round-closed';throw Error('unknown');}return {};},readBudget:()=>({deadline:Date.now()+10000,calls:nativeCalls,bytes:1000,rows:50,closed:ownerClosed}),close:()=>{closed++;ownerClosed=true;calls.push('close-session');}};
const security = {accessGeneration:async()=>'access',decryptSecret:async x=>x,encryptSecret:async x=>x,fingerprint:JSON.stringify,McpError:FixtureMcpError,privateSecretFingerprint:async()=>'seal',redact:x=>x};
const { readScopedPathCheckpoint } = await loadModel('./routing-community-scoped-request.ts', name => {
if (name === './path-proof-purpose.js') return pathProofPurpose;
if (name === './security.js') return security;
return require(name);
});
const pathCheckId = randomUUID(), observedAt = new Date();
const pathCheckpoint = { id: pathCheckId, connection_id: principal.connectionId, status: 'passed', created_at: observedAt, evidence: { observedAt: observedAt.toISOString() } };
const providers={
'node:crypto':{randomUUID},zod:{z},'@mmapp/contracts/mcp-routing':{routeAiProfileSchema:{parse:x=>x}},'../../db/index.js':{pool:{query:async sql=>({rows:String(sql).includes('FROM mcp_path_checks')?[{id:randomUUID(),connection_id:principal.connectionId,status:'passed',created_at:new Date(),evidence:{}}]:[]})}},
'node:crypto':{randomUUID},zod:{z},'@mmapp/contracts/mcp-routing':{routeAiProfileSchema:{parse:x=>x}},'../../db/index.js':{pool:{query:async (sql, values)=>({rows:String(sql).includes('FROM mcp_path_checks')&&values?.[0]===pathCheckId&&values?.[1]===principal.connectionId?[pathCheckpoint]:[]})}},
'../../lib/permissions.js':{hasPermission:(list,value)=>list.includes(value)},
'../../services/config-mutation-policy.js':{assertVerifiedConfigurationAccess:async()=>{},executeGuardedConfigurationWrite:async(_sid,_method,_path,work)=>{calls.push('guarded-intent');return work();}},
'../../services/config-mutation-lock.js':{withApplicationMutationLock:work=>work(),withConfigMutationLock:(_ids,work)=>work()},
'./store.js':{effectiveMcpConnection:async()=>connection,audit:async()=>{}},
'./security.js':{accessGeneration:async()=>'access',decryptSecret:async x=>x,encryptSecret:async x=>x,fingerprint:JSON.stringify,McpError:Error,privateSecretFingerprint:async()=>'seal',redact:x=>x},
'../../services/routing-filter-body.js': filterBody,
'./store.js':{effectiveMcpConnection:async()=>connection,audit:async(_db,_owner,action)=>{calls.push(action);}},
'./security.js':security,
'./routing-community-scoped-checkpoint.js':{saveScopedConfigurationCheckpoint:async()=>{calls.push('CONFIG_APPLIED');},loadScopedConfigurationCheckpoint:async()=>({checkpointProof:'configuration'}),saveScopedNetworkCheckpoint:async(_input,_seal,check,originalEffects)=>{calls.push('save-network');network={status:check.status,networkSeal:'network',...(originalEffects?{originalEffects}:{})};},loadScopedNetworkCheckpoint:async()=>network},
'./routing-community-scoped-request.js':{assertScopedCommunityRequest:async()=>'request'},'./management-transport.js':{prepareManagementTransportBinding:async()=>({})},
'./routing-community-scoped-request.js':{assertScopedCommunityRequest:async()=>'request',readScopedPathCheckpoint},'./management-transport.js':{prepareManagementTransportBinding:async()=>({})},
'./routing-community-context.js':{readRouteCommunityContext:async()=>{reads++;return {before:{scopedExecutionAvailable:true,currentCandidateId:wrote?'b':'a',dependencyProof:'dep',sourceProofs:[{serverId:1,proof:'source'}]},ownedTarget:{nativeId:'*1',rawRow:{rule:wrote?'desired':'original'},endpoint:{},desiredTargetProof:()=> 'desired-proof'},fence:async()=>{},clients:new Map([[1,{get:async()=>[{'.id':'*r'}]}]]),captureOriginalEffectSelection:async()=>{calls.push('capture-prepared-selection');return input.originalEffectSelection;}};}},
'./routing-community-context-model.js':{candidateMatchesGateway:()=>true},'./routing-model.js':{rankRoutingCandidates:()=>profile.candidates.map(candidate=>({candidate,eligible:true}))},'./routing-observations.js':{readRoutingObservations:async()=>[]},
'./path-verification.js':{requireRecentPath:async()=>'path-check',checkCandidatePath:async()=>{calls.push('legacy-probe');return {id:randomUUID(),candidateId:'b',status:'passed',observedAt:new Date().toISOString()};}},
'./path-verification.js':{requireRecentPath:async()=>pathCheckId,checkCandidatePath:async()=>{calls.push('legacy-probe');return {id:randomUUID(),candidateId:'b',status:'passed',observedAt:new Date().toISOString()};}},
'./routing-community-scoped-model.js':{compileScopedCommunitySwitch:()=>({compiledBody:'desired',proposed:{community:'65000:10',gateway:'192.0.2.9',outIface:''},before:{community:'65000:10',gateway:'192.0.2.8',outIface:''}}),scopedConfigurationMatches:()=>({configurationMatchesOriginal:!wrote,configurationMatchesDesired:wrote&&!foreign})},
'./routing-community-scoped-native.js':{patchScopedCommunity:async()=>{calls.push('PATCH');wrote=true;}},'./service-registry.js':{registerMcpService:(_name,value)=>{handler=value;}},
'./routing-profile-authority.js':{assertPersistentScopedProfileAuthority:async()=>{},resolveRoutingProfileAuthority:async()=>{},routingProfileAuthorityBindingSchema:{parse:x=>x},scopedProfileAuthorityTransport:async()=>({})},
@@ -33,19 +64,57 @@ async function fixture(mode='prepared',failure){
'./routing-network-execution-session.js':{createRoutingNetworkSessionForCurrentExecution:async options=>{calls.push('open-session');assert.deepEqual(Object.keys(options).sort(),['probes','servers']);if(failure==='open')throw Error('unknown');return session;}},
'./routing-network-configuration-bridge.js':{resolveRoutingNetworkForwardRoundConfigurationContext:async()=>{calls.push('configuration-round-owner');return {};},closeRoutingNetworkForwardRoundConfigurationContext:()=>{calls.push('close-round-owner');}},
'./routing-network-scoped-context.js':{readRoutingNetworkScopedCommunityContext:async()=>{calls.push('shared-publication');if(failure==='publication')throw Error('DO_NOT_EXPOSE_TEST_DETAIL');nativeCalls+=10;return{before:{dependencyProof:'dep',targetRuleProof:'desired-proof',currentCandidateId:'b'},fence:async()=>{}};}},
'./routing-original-effects.js':{originalEffectSelectionSchema:{safeParse:x=>x?{success:true,data:x}:{success:false}},unknownOriginalEffects:unknown,originalEffectBaselineMatches:()=>failure!=='baseline',inspectOriginalEffectRounds:async()=>{calls.push('inspect-effects');if(failure==='foreign')foreign=true;return report;}}
'./routing-original-effects.js':{originalEffectReportSchema,originalEffectSelectionSchema:{safeParse:x=>x?{success:true,data:x}:{success:false}},unknownOriginalEffects:unknown,originalEffectBaselineMatches:()=>failure!=='baseline',inspectOriginalEffectRounds:async()=>{calls.push('inspect-effects');if(failure==='foreign')foreign=true;return report;}}
};
const exports={};new Function('exports','require',code)(exports,name=>{assert.ok(providers[name],name);return providers[name];});exports.registerScopedCommunityService();
return {calls,input,run:()=>handler.execute(input,{operationId:randomUUID(),phase:'forward',stepIndex:0}),prepare:()=>exports.prepareScopedCommunitySwitch({profile,candidateId:'b'},{principal,connection}),stats:()=>({wrote,closed,reads}),network:()=>network};
}
test('actual service consumer baselines before the single PATCH and persists separate effects after genuine CONFIG_APPLIED and manual probe',async()=>{const f=await fixture(),result=await f.run();const c=f.calls;assert(c.indexOf('baseline:before')<c.indexOf('PATCH'));assert(c.indexOf('CONFIG_APPLIED')<c.indexOf('configuration-round-owner'));assert(c.indexOf('legacy-probe')<c.indexOf('round:1'));assert(c.indexOf('round:2')<c.indexOf('shared-publication'));assert(c.indexOf('shared-publication')<c.indexOf('save-network'));assert.equal(c.filter(x=>x==='PATCH').length,1);assert.equal(result.configurationVerified,true);assert.equal(result.controlProbeVerified,true);assert.equal(result.forwardingVerified,false);assert.equal(result.originalEffects.status,'observed-expected');assert.equal(result.originalEffects.budget.calls,110);assert.equal(f.stats().closed,1);assert.deepEqual(result.originalEffects,f.network().originalEffects);});
for(const mode of ['legacy','unknown'])test(`existing ${mode} manual path does not open native effect session`,async()=>{const f=await fixture(mode),result=await f.run();assert(!f.calls.includes('open-session'));assert.equal(result.configurationVerified,true);assert.equal(result.controlProbeVerified,true);assert.equal(f.calls.filter(x=>x==='PATCH').length,1);});
for(const failure of ['open','baseline','round','round-closed','publication'])test(`optional ${failure} uncertainty preserves actual manual config/control receipt with effects unknown`,async()=>{const f=await fixture('prepared',failure),result=await f.run();assert.equal(result.configurationVerified,true);assert.equal(result.controlProbeVerified,true);assert.equal(result.originalEffects.status,'unknown');assert.equal(f.calls.filter(x=>x==='PATCH').length,1);assert(f.calls.includes('save-network'));});
for (const mode of ['legacy', 'unknown']) test(`existing ${mode} manual path does not open native effect session`, async () => {
const f = await fixture(mode, undefined, { proofRevision: 2 });
const result = await f.run();
assert(!f.calls.includes('open-session'));
assert.equal(result.configurationVerified, true);
assert.equal(result.controlProbeVerified, true);
assert.equal(f.calls.filter(call => call === 'PATCH').length, 1);
});
for (const failure of ['open', 'baseline', 'round', 'round-closed', 'publication']) test(`optional ${failure} uncertainty preserves config/control receipt with effects unknown`, async () => {
const f = await fixture('prepared', failure, ['open', 'baseline'].includes(failure)
? { proofRevision: 2, manualEffectPolicy: 'observational-v1' }
: { proofRevision: 3 });
const result = await f.run();
assert.equal(result.configurationVerified, true);
assert.equal(result.controlProbeVerified, true);
assert.equal(result.originalEffects.status, 'unknown');
assert.equal(f.calls.filter(call => call === 'PATCH').length, 1);
assert(f.calls.includes('save-network'));
});
for (const [description, mode, failure] of [
['missing selection', 'legacy'],
['unknown selection', 'unknown'],
['unavailable native session', 'prepared', 'open'],
['baseline drift', 'prepared', 'baseline'],
]) {
test(`persistent revision 3 refuses ${description} before dispatch`, async () => {
const f = await fixture(mode, failure, { proofRevision: 3 });
await assert.rejects(f.run, { code: 'MEASUREMENT_UNKNOWN' });
for (const call of ['routing.scoped.attempt', 'guarded-intent', 'PATCH', 'CONFIG_APPLIED', 'save-network']) {
assert(!f.calls.includes(call), call);
}
});
}
test('foreign configuration drift after optional effect observations still refuses publication, never repeats PATCH',async()=>{const f=await fixture('prepared','foreign');await assert.rejects(f.run);assert.equal(f.calls.filter(x=>x==='PATCH').length,1);assert(!f.calls.includes('save-network'));assert.equal(f.stats().closed,1);});
test('revision2 remains on existing manual path even with a prepared-like field',async()=>{const f=await fixture();f.input.proofRevision=2;await f.run();assert(!f.calls.includes('open-session'));});
test('sealed manual policy runs read-only rounds through the real scoped consumer',async()=>{const f=await fixture();f.input.proofRevision=2;f.input.manualEffectPolicy='observational-v1';const result=await f.run();assert.equal(f.calls.filter(x=>x==='PATCH').length,1);assert(f.calls.includes('round:2'));assert.equal(result.originalEffects.status,'observed-expected');});
test('actual preparation consumes the required real-context selection method and includes it in sealed service input',async()=>{const f=await fixture(),evidence=await f.prepare();assert(f.calls.includes('capture-prepared-selection'));assert.deepEqual(evidence.steps[0].input.originalEffectSelection,f.input.originalEffectSelection);assert.equal(evidence.steps[0].input.requestProof,'seal');assert(!f.calls.includes('PATCH'));});
test('closed effect owner falls back to exactly the old final context read without bypassing publication guard',async()=>{const legacy=await fixture('legacy');await legacy.run();const f=await fixture('prepared','round-closed');await f.run();assert(!f.calls.includes('shared-publication'));assert.equal(f.stats().reads,legacy.stats().reads);});
test('closed effect owner falls back to exactly the old final context read without bypassing publication guard', async () => {
const legacy = await fixture('legacy', undefined, { proofRevision: 2 });
await legacy.run();
const f = await fixture('prepared', 'round-closed', { proofRevision: 2, manualEffectPolicy: 'observational-v1' });
await f.run();
assert(!f.calls.includes('shared-publication'));
assert.equal(f.stats().reads, legacy.stats().reads);
});
test('optional publication error exposes only a bounded failure category', async()=>{
const f=await fixture('prepared','publication'), result=await f.run();
Loaded 100 of 239 files, more files were not shown because too many files have changed in this diff. Show more