Files
MikrotikManager/backend/src/modules/mcp/routing-community-scoped-checkpoint.ts
T

99 lines
10 KiB
TypeScript

import { originalEffectReportSchema, type OriginalEffectReport } from "./routing-original-effects.js"
import { z } from "zod"
import type { PoolClient } from "pg"
import { pool } from "../../db/index.js"
import { audit, transaction } from "./store.js"
import { decryptSecret, encryptSecret, fingerprint, McpError, privateSecretFingerprint, redact } from "./security.js"
import { assertPersistentScopedProfileAuthority, routingProfileAuthorityBindingSchema } from "./routing-profile-authority.js"
const proof = z.string().regex(/^[a-f0-9]{64}$/)
const body = z.string().max(65536).refine(value => Buffer.byteLength(value, "utf8") <= 65536)
const manualCheckpointSchema = z.object({
revision: z.literal(1), phase: z.literal("CONFIG_APPLIED"), operationId: z.string().uuid(), stepIndex: z.number().int().nonnegative(),
connectionId: z.string().uuid(), ownerId: z.string(), receiptId: z.string().uuid(), requestSeal: proof,
serverId: z.number().int().positive(), nativeId: z.string().regex(/^\*[a-z0-9]+$/i),
originalCandidateId: z.string(), desiredCandidateId: z.string(), profileProof: proof,
originalRule: body, desiredRule: body, dependencyProof: proof, originalTargetProof: proof, desiredTargetProof: proof,
contextSources: z.array(z.object({ serverId: z.number().int().positive(), proof })).max(1000), observedAt: z.string().datetime(),
}).strict()
const checkpointSchema = z.union([manualCheckpointSchema, manualCheckpointSchema.extend({ revision: z.literal(2), persistentProfileAuthority: routingProfileAuthorityBindingSchema }).strict()])
export type ScopedConfigurationPacket = z.infer<typeof checkpointSchema>
type Checkpoint = ScopedConfigurationPacket
const networkSchema = z.object({ receiptId: z.string().uuid(), requestSeal: proof, configurationCheckpointProof: proof,
check: z.object({ id: z.string().uuid(), candidateId: z.string(), status: z.enum(["passed", "failed"]), observedAt: z.string().datetime() }).strict(), originalEffects: originalEffectReportSchema.optional(), networkSeal: proof }).strict()
const intended = (input: Record<string, unknown>) => JSON.stringify(redact({ kind: "service", service: "routing.community.scoped", input }))
const connection = (input: Record<string, unknown>) => z.object({ connectionId: z.string().uuid() }).parse(input.principal).connectionId
const seal = (value: Checkpoint) => privateSecretFingerprint(fingerprint(value), `route-ai-scoped-config-checkpoint-v${value.revision}`)
/** The record belongs to the exact running operation/step, not just a matching
* device. One atomic commit includes the encrypted proof and its audit event. */
export async function saveScopedConfigurationCheckpoint(input: Record<string, unknown>, requestSeal: string) {
await assertPersistentScopedProfileAuthority(input)
const originalRule = await decryptSecret(String(input.originalRuleCiphertext)), desiredRule = await decryptSecret(String(input.desiredRuleCiphertext))
return transaction(async db => {
const rows = (await db.query("SELECT o.id,s.step_index FROM mcp_operations o JOIN mcp_operation_steps s ON s.operation_id=o.id WHERE o.connection_id=$1 AND o.state='running' AND s.state='intent' AND s.intended_state=$2::jsonb LIMIT 2 FOR UPDATE OF o,s", [connection(input), intended(input)])).rows
if (rows.length !== 1) throw new McpError("STATE_CONFLICT", "Не подтверждён exact scoped operation intent", 409)
const packet = checkpointSchema.parse({ revision: input.persistentProfileAuthority === undefined ? 1 : 2, phase: "CONFIG_APPLIED", operationId: rows[0].id, stepIndex: rows[0].step_index,
connectionId: connection(input), ownerId: input.ownerId, receiptId: input.receiptId, requestSeal, serverId: input.serverId, nativeId: input.nativeId,
originalCandidateId: input.originalCandidateId, desiredCandidateId: input.candidateId, profileProof: fingerprint(input.profile),
originalRule, desiredRule, dependencyProof: input.dependencyProof, originalTargetProof: input.originalTargetProof, desiredTargetProof: input.desiredTargetProof,
contextSources: input.contextSources, observedAt: new Date().toISOString(),
...(input.persistentProfileAuthority === undefined ? {} : { persistentProfileAuthority: input.persistentProfileAuthority }) })
const checkpointProof = await seal(packet)
const ciphertext = await encryptSecret(JSON.stringify(packet))
await assertPersistentScopedProfileAuthority(input)
const changed = await db.query("UPDATE mcp_operation_checkpoints SET configuration_sealed=$3,configuration_proof=$4 WHERE operation_id=$1 AND step_index=$2 AND configuration_sealed IS NULL AND configuration_proof IS NULL RETURNING operation_id", [packet.operationId, packet.stepIndex, ciphertext, checkpointProof])
if (changed.rowCount !== 1) throw new McpError("STATE_CONFLICT", "Scoped configuration checkpoint уже существует или недоступен", 409)
await audit(db, packet.ownerId, "routing.scoped.config-applied", packet.connectionId, { operationId: packet.operationId, stepIndex: packet.stepIndex, receiptId: packet.receiptId, requestSeal, checkpointProof, phase: packet.phase })
return { checkpointProof, observedAt: packet.observedAt }
})
}
/** Read-only. A matching configuration is never promoted to a missing record. */
export async function loadScopedConfigurationPacket(input: Record<string, unknown>, requestSeal: string, source?: { operationId: string; stepIndex: number }, db: Pick<PoolClient, "query"> = pool) {
const rows = (await db.query(`SELECT o.id,s.step_index,c.configuration_sealed,c.configuration_proof FROM mcp_operations o JOIN mcp_operation_steps s ON s.operation_id=o.id JOIN mcp_operation_checkpoints c ON c.operation_id=o.id AND c.step_index=s.step_index WHERE o.connection_id=$1 AND s.intended_state=$2::jsonb${source ? " AND o.id=$3 AND s.step_index=$4" : ""} LIMIT 2`, [connection(input), intended(input), ...(source ? [source.operationId, source.stepIndex] : [])])).rows
if (rows.length !== 1 || !rows[0].configuration_sealed || !rows[0].configuration_proof) return null
const row = rows[0]
if (Buffer.byteLength(row.configuration_sealed) > 524288) throw new McpError("STATE_UNAVAILABLE", "Scoped checkpoint превышает предел", 409)
try {
const packet = checkpointSchema.parse(JSON.parse(await decryptSecret(row.configuration_sealed)))
const expected = { connectionId: connection(input), ownerId: input.ownerId, receiptId: input.receiptId, requestSeal, serverId: input.serverId, nativeId: input.nativeId,
originalCandidateId: input.originalCandidateId, desiredCandidateId: input.candidateId, profileProof: fingerprint(input.profile), dependencyProof: input.dependencyProof,
originalTargetProof: input.originalTargetProof, desiredTargetProof: input.desiredTargetProof, contextSources: input.contextSources }
if (packet.revision !== (input.persistentProfileAuthority === undefined ? 1 : 2) ||
packet.revision === 2 && fingerprint(packet.persistentProfileAuthority) !== fingerprint(input.persistentProfileAuthority)) return null
if (packet.operationId !== row.id || packet.stepIndex !== row.step_index || row.configuration_proof !== await seal(packet) || Object.entries(expected).some(([key, value]) => fingerprint(packet[key as keyof Checkpoint]) !== fingerprint(value))) return null
if (packet.originalRule !== await decryptSecret(String(input.originalRuleCiphertext)) || packet.desiredRule !== await decryptSecret(String(input.desiredRuleCiphertext))) return null
return { checkpointProof: row.configuration_proof as string, observedAt: packet.observedAt, packet }
} catch { return null }
}
export async function loadScopedConfigurationCheckpoint(input: Record<string, unknown>, requestSeal: string, db: Pick<PoolClient, "query"> = pool) {
const value = await loadScopedConfigurationPacket(input, requestSeal, undefined, db)
return value ? { checkpointProof: value.checkpointProof, observedAt: value.observedAt } : null
}
export async function saveScopedNetworkCheckpoint(input: Record<string, unknown>, requestSeal: string, check: { id: string; candidateId: string; status: "passed" | "failed"; observedAt: string }, originalEffects?: OriginalEffectReport) {
await assertPersistentScopedProfileAuthority(input)
const configuration = await loadScopedConfigurationCheckpoint(input, requestSeal)
if (!configuration) throw new McpError("STATE_CONFLICT", "Нет подтверждённого CONFIG_APPLIED", 409)
const packet = { receiptId: input.receiptId, requestSeal, configurationCheckpointProof: configuration.checkpointProof,
check: { id: check.id, candidateId: check.candidateId, status: check.status, observedAt: check.observedAt },
...(originalEffects === undefined ? {} : { originalEffects: originalEffectReportSchema.parse(originalEffects) }) }
const networkSeal = await privateSecretFingerprint(fingerprint(packet), "route-ai-scoped-network-checkpoint-v1")
await assertPersistentScopedProfileAuthority(input)
await audit(pool, String(input.ownerId), "routing.scoped.network-check", connection(input), { ...packet, networkSeal })
}
export async function loadScopedNetworkCheckpoint(input: Record<string, unknown>, requestSeal: string, db: Pick<PoolClient, "query"> = pool) {
const configuration = await loadScopedConfigurationCheckpoint(input, requestSeal, db)
if (!configuration) return null
const rows = (await db.query("SELECT payload FROM mcp_audit WHERE connection_id=$1 AND actor_id=$2 AND action='routing.scoped.network-check' AND payload->>'receiptId'=$3 AND payload->>'requestSeal'=$4 LIMIT 2", [connection(input), input.ownerId, input.receiptId, requestSeal])).rows
if (rows.length !== 1) return null
const parsed = networkSchema.safeParse(rows[0].payload)
if (!parsed.success) return null
const { networkSeal, ...packet } = parsed.data
if (packet.configurationCheckpointProof !== configuration.checkpointProof || packet.check.candidateId !== input.candidateId || Date.parse(packet.check.observedAt) < Date.parse(configuration.observedAt) || Date.parse(packet.check.observedAt) > Date.now() || networkSeal !== await privateSecretFingerprint(fingerprint(packet), "route-ai-scoped-network-checkpoint-v1")) return null
return { status: packet.check.status, networkSeal, ...(packet.originalEffects === undefined ? {} : { originalEffects: packet.originalEffects }) }
}