Files
MikrotikManager/backend/src/modules/mcp/routing-controller-active-writer.ts
T

86 lines
7.0 KiB
TypeScript

import { loadRoutingMainTransitionPreparation } from "./routing-main-transition.js"
import { assertScopedCommunityRequest } from "./routing-community-scoped-request.js"
import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing"
import { pool } from "../../db/index.js"
import { captureVerifiedMcpServiceExecution, inspectVerifiedMcpServiceExecution } from "../../services/config-mutation-policy.js"
import { effectiveMcpConnection } from "./store.js"
import { routingProfileAuthorityBindingSchema, assertPersistentScopedProfileAuthority } from "./routing-profile-authority.js"
import { loadScopedRecoverySource } from "./routing-community-scoped-recovery-source.js"
import { authenticateRoutingControllerBaseline, routingControllerBaselinePrincipal } from "./routing-controller-baseline.js"
import { fingerprint, McpError } from "./security.js"
import { assertPersistentRoutingForwardTransportCharge } from "./routing-reservation-store.js"
/** Current native boundary only. Called under existing mm-config server locks.
* This denies an unrelated REST/MCP native mutation; it creates no debit,
* dispatch capability, READY, enrollment, inverse or log coverage proof. */
export async function assertActiveRoutingProfileWriter(serverId:number) {
const deny=()=>new McpError("STATE_CONFLICT","Активный routing profile требует исходного проверенного шага; приостановите профиль перед ручным изменением",409)
if(!Number.isSafeInteger(serverId)||serverId<=0)throw deny()
const rows=(await pool.query(`SELECT id,version,connection_id,grant_expires_at,
CASE WHEN octet_length(config::text)<=262144 THEN config END AS config,
CASE WHEN octet_length(state::text)<=131072 THEN state END AS state,
(config IS NULL OR state IS NULL OR octet_length(config::text)>262144 OR octet_length(state::text)>131072) AS source_bounds_unavailable
FROM mcp_routing_profiles WHERE enabled AND (config->>'serverId'=$1 OR EXISTS(
SELECT 1 FROM jsonb_array_elements(config->'candidates') AS candidate
WHERE candidate->>'jumpHostId'=$1 OR candidate->>'exitNodeId'=$1)) ORDER BY id LIMIT 2`,[String(serverId)])).rows
// Distinct profile IDs cannot share the one original persistent authority
// below. Two rows therefore prove ambiguity; never hydrate another999 rows.
// At most2 * (256KiB config +128KiB state) leave SQL, plus fixed metadata.
if(rows.length>1||rows.some(row=>row.source_bounds_unavailable===true))throw deny()
if(!rows.length)return
let origin
try { origin=await captureVerifiedMcpServiceExecution() } catch { throw deny() }
const metadata=await inspectVerifiedMcpServiceExecution(origin)
if(metadata.phase!=="forward"||metadata.stepIndex!==0)throw deny()
let input:Record<string,unknown>, recoverySource:Awaited<ReturnType<typeof loadScopedRecoverySource>>|undefined
if(metadata.operationName==="routing.community.switch"&&metadata.operationVersion===7&&["routing.community.scoped","routing.community"].includes(metadata.serviceName))input=metadata.serviceInput
else if(metadata.operationName==="operations.recover"&&metadata.serviceName==="routing.community.scoped.recover"){
const principal=metadata.principal,connection=await effectiveMcpConnection(principal)
const source=await loadScopedRecoverySource(String(metadata.serviceInput.sourceOperationId),{principal,connection})
recoverySource=source
if(metadata.serviceInput.sourceProof!==source.sourceProof || metadata.serviceInput.configurationProof!==source.configuration.checkpointProof ||
metadata.serviceInput.sourceReceiptId!==source.input.receiptId)throw deny()
input=source.input
}else throw deny()
const authority=routingProfileAuthorityBindingSchema.parse(input.persistentProfileAuthority)
await assertPersistentScopedProfileAuthority(input)
for(const row of rows){
if(row.id!==authority.packet.profileId||row.version!==authority.packet.profileVersion||row.connection_id!==authority.packet.connectionId||!(row.grant_expires_at instanceof Date))throw deny()
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}, 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)
if(!matches({nativeId:prepared.nativeId,candidateId:prepared.originalCandidateId,targetProof:prepared.originalTargetProof,
dependencyProof:prepared.dependencyProof,sourceProofs:prepared.contextSources}))throw deny()
}else if(!recoverySource){
await assertScopedCommunityRequest(input)
if(!matches({nativeId:input.nativeId,candidateId:input.originalCandidateId,targetProof:input.originalTargetProof,
dependencyProof:input.dependencyProof,sourceProofs:input.contextSources}))throw deny()
}else{
// The inverse writes B->A. It is admitted either while the ORIGINAL A
// baseline awaits its own B transition, or from the EXACT signed B
// baseline advanced by this very source operation/checkpoint. Never
// compare inverse before-state B naively to forward original-state A.
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 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()
}
}
// The live origin and baseline cannot replace a durable admission charge.
// Existing reserved forward capability and manual inverse stamp paths remain
// separate; this check grants nothing and never infers a legacy exemption.
if (!recoverySource && !routeAiProfileSchema.parse(input.profile).autoRecovery) {
await assertPersistentRoutingForwardTransportCharge(pool, metadata, metadata.ownerId)
}
await inspectVerifiedMcpServiceExecution(origin)
}