196 lines
16 KiB
TypeScript
196 lines
16 KiB
TypeScript
import { SERVER_POLL_WALL_BUDGET_MS } from "./server-poll-limits.js"
|
|
/** Current authority for complete poll/partition maintenance. Trusted constructors
|
|
* only; importing this module neither enrolls schema nor performs I/O. */
|
|
import type { PoolClient } from "pg"
|
|
import { z } from "zod"
|
|
import { mcpProtectedObjectSchema } from "@mmapp/contracts/mcp"
|
|
import { hasPermission, type AuthUser } from "../lib/permissions.js"
|
|
import { fixedPrincipalBoundarySource, readSynchronousPrincipalBoundary, principalBoundaryProjection, type PrincipalBoundaryPacket, effectiveMcpConnectionSource, type McpPrincipal } from "../modules/mcp/store.js"
|
|
import { captureRuntimePollPrincipalBoundary, coordinateRuntimePollPrincipalBoundary, type RuntimePollBoundary } from "../modules/mcp/runtime-poll-principal-boundary.js"
|
|
import { assertPortalClaimsCurrent } from "../modules/mcp/portal-revocation.js"
|
|
import { accessGeneration, mcpStateDirectory, fingerprint, McpError } from "../modules/mcp/security.js"
|
|
import { connectProbeStorage } from "./server-probe-storage-connection.js"
|
|
import type { ProbeStorageConnection, ProbeStorageOptions, ProbeStorageSql } from "./server-probe-journal-maintenance.js"
|
|
import type { ServerPollDependencies, ServerPollGrant } from "./server-poll-execution.js"
|
|
|
|
export const SERVER_POLL_OPERATION = "servers.poll"
|
|
export interface ServerPollAuthorityOptions { deadlineAt?: number; signal?: AbortSignal }
|
|
export interface ServerPollRestAuthorityContext extends ServerPollAuthorityOptions { authRequired: boolean; acceptedAt: number; requestId: string; assertRequestActive?: () => void }
|
|
type Dependencies = { connect: (options: ProbeStorageOptions) => Promise<ProbeStorageConnection>; connectionSource: typeof effectiveMcpConnectionSource;
|
|
portal: typeof assertPortalClaimsCurrent; generation: typeof accessGeneration }
|
|
const production: Dependencies = { connect: connectProbeStorage, connectionSource: effectiveMcpConnectionSource, portal: assertPortalClaimsCurrent, generation: accessGeneration }
|
|
const fail = (code = "PERMISSION_DENIED", status = 403): never => { throw new McpError(code,
|
|
code === "NOT_FOUND" ? "Server not found" : code === "INVALID_INPUT" ? "Некорректный контекст полного опроса" : "Актуальный допуск полного опроса не подтверждён", status) }
|
|
const live = (deadlineAt: number, signal?: AbortSignal) => { if (signal?.aborted || Date.now() >= deadlineAt) fail("READ_CANCELLED", 409) }
|
|
const identifier = z.string().min(1).max(200).refine(value => value.trim() === value && !/[\x00-\x1f\x7f]/.test(value))
|
|
const principalSchema = z.object({ connectionId: z.string().uuid(), credentialId: z.string().uuid(), credentialExpiresAt: z.string().datetime() }).strict()
|
|
function deadline(options: ServerPollAuthorityOptions, expiresAt: number) {
|
|
const now = Date.now(), value = Math.min(options.deadlineAt ?? now + SERVER_POLL_WALL_BUDGET_MS, now + SERVER_POLL_WALL_BUDGET_MS, expiresAt)
|
|
if (!Number.isSafeInteger(value) || options.deadlineAt !== undefined && !Number.isSafeInteger(options.deadlineAt)) fail("INVALID_INPUT", 400)
|
|
live(value, options.signal); return value
|
|
}
|
|
function ids(value: unknown): number[] {
|
|
if (!Array.isArray(value) || value.length > 1000 || value.some(id => !Number.isSafeInteger(id) || id <= 0) || new Set(value).size !== value.length) fail("STATE_UNAVAILABLE", 503)
|
|
return [...value as number[]].sort((left, right) => left - right)
|
|
}
|
|
function safe(error: unknown): never {
|
|
const code = error instanceof McpError ? error.code : "STATE_UNAVAILABLE"
|
|
if (["NOT_FOUND", "INVALID_INPUT", "READ_CANCELLED", "STATE_CONFLICT", "STATE_UNAVAILABLE", "PROTECTED_OBJECT", "APPROVAL_REQUIRED"].includes(code)) {
|
|
return fail(code, code === "NOT_FOUND" ? 404 : code === "INVALID_INPUT" ? 400 : code === "STATE_UNAVAILABLE" ? 503 : 409)
|
|
}
|
|
return fail("PERMISSION_DENIED", 403)
|
|
}
|
|
|
|
/** Owns a fresh READ COMMITTED read-only metadata lease, never the maintenance
|
|
* backend. Whole callback/checkout includes crypto and portal filesystem gaps.
|
|
* Completion/abort expires the facade; late work cannot reacquire or issue SQL. */
|
|
async function metadata<T>(deps: Dependencies, options: ProbeStorageOptions & { assertRequestActive?: () => void }, work: (sql: ProbeStorageSql, client: Pick<PoolClient, "query">, owner: { signal: AbortSignal; assertActive(): void; retireUnsafeOwner(): void }) => Promise<T>) {
|
|
let active = true, released = false, broken = false, busy = false, transaction = false
|
|
const lifetime = new AbortController()
|
|
let physical: ProbeStorageConnection | undefined
|
|
const close = (unsafe: boolean) => { lifetime.abort(); if (physical && !released) { released = true; try { physical.release(unsafe) } catch { /* No driver details escape. */ } } }
|
|
const fence = () => { options.assertRequestActive?.(); live(options.deadlineAt, options.signal); if (!active || broken) fail("STATE_UNAVAILABLE", 503) }
|
|
let stop!: () => void
|
|
const stopped = new Promise<never>((_yes, reject) => { stop = () => { active = false; broken = true; close(true); reject(new McpError("READ_CANCELLED", "Полный опрос отменён или истёк", 409)) } })
|
|
const timer = setTimeout(stop, Math.max(1, options.deadlineAt - Date.now()))
|
|
options.signal?.addEventListener("abort", stop, { once: true })
|
|
const sql: ProbeStorageSql = Object.freeze({ async query(text: string, values?: unknown[]) {
|
|
fence(); const connection = physical
|
|
if (!connection || busy) { stop(); return fail("STATE_UNAVAILABLE", 503) }
|
|
busy = true
|
|
try { const result = await connection.query(text, values, { signal: options.signal, timeoutMs: Math.max(1, Math.min(10000, options.deadlineAt - Date.now())) }); fence(); return result }
|
|
catch (error) { broken = true; close(true); throw error }
|
|
finally { busy = false }
|
|
} })
|
|
const completion = Promise.resolve().then(async () => {
|
|
fence(); physical = await deps.connect({ deadlineAt: options.deadlineAt, signal: options.signal })
|
|
try { fence() } catch (error) { close(true); throw error }
|
|
if (!active || options.signal?.aborted || Date.now() >= options.deadlineAt) { close(true); fail("READ_CANCELLED", 409) }
|
|
try {
|
|
await sql.query("BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY"); transaction = true
|
|
await sql.query("SET LOCAL statement_timeout='10s'")
|
|
const value = await work(sql, { query: sql.query as PoolClient["query"] }, {
|
|
signal: lifetime.signal, assertActive() { fence(); if (!transaction || busy) fail("STATE_UNAVAILABLE", 503) }, retireUnsafeOwner: stop,
|
|
}); fence(); return value
|
|
} finally {
|
|
if (transaction && active && !broken && !busy && !options.signal?.aborted && Date.now() < options.deadlineAt) { await sql.query("ROLLBACK"); transaction = false }
|
|
}
|
|
})
|
|
try { if (options.signal?.aborted || Date.now() >= options.deadlineAt) stop(); const value = await Promise.race([completion, stopped]); fence(); return value }
|
|
finally { active = false; clearTimeout(timer); options.signal?.removeEventListener("abort", stop); close(broken || busy || transaction) }
|
|
}
|
|
|
|
async function catalogue(sql: ProbeStorageSql, serverId: number, affected: number[]) {
|
|
if (!Number.isSafeInteger(serverId) || serverId <= 0) fail("INVALID_INPUT", 400)
|
|
const rows = (await sql.query("SELECT id FROM servers ORDER BY id LIMIT 1001")).rows
|
|
if (!Array.isArray(rows) || rows.some(row => Object.keys(row).join(" ") !== "id")) fail("STATE_UNAVAILABLE", 503)
|
|
const all = ids(rows.map(row => row.id))
|
|
if (rows.some((row, index) => row.id !== all[index])) fail("STATE_UNAVAILABLE", 503)
|
|
if (!all.includes(serverId)) fail("NOT_FOUND", 404)
|
|
if (affected.some(id => !all.includes(id))) fail("STATE_CONFLICT", 409)
|
|
return all
|
|
}
|
|
async function protections(sql: ProbeStorageSql, humanPlan: boolean) {
|
|
const rows = (await sql.query("SELECT object_ref FROM mcp_protections ORDER BY id LIMIT 1001")).rows
|
|
if (!Array.isArray(rows) || rows.length > 1000) fail("STATE_UNAVAILABLE", 503)
|
|
const refs = rows.map(row => { if (Object.keys(row).join(" ") !== "object_ref") fail("STATE_UNAVAILABLE", 503); return mcpProtectedObjectSchema.parse(row.object_ref) })
|
|
// Poll maintenance is global. A scoped deny must not be bypassed by dropping
|
|
// history/partitions outside the source server selected in the REST URL.
|
|
if (refs.some(ref => ref.protection === "deny")) fail("PROTECTED_OBJECT", 409)
|
|
if (!humanPlan && refs.length) fail("APPROVAL_REQUIRED", 409)
|
|
return refs
|
|
}
|
|
const grant = (serverIds: number[], proof: unknown): ServerPollGrant => Object.freeze({ global: true,
|
|
serverIds: Object.freeze([...serverIds]) as unknown as number[], authorityProof: fingerprint(proof) })
|
|
|
|
function mcpAuthority(rawPrincipal: McpPrincipal, options: ServerPollAuthorityOptions, deps: Dependencies, runtimeBoundary?: RuntimePollBoundary): ServerPollDependencies["authorize"] {
|
|
const principal = Object.freeze(principalSchema.parse(structuredClone(rawPrincipal))), signal = options.signal
|
|
const deadlineAt = deadline(options, Date.parse(principal.credentialExpiresAt))
|
|
return async (request, affected, extraSignal) => {
|
|
const combined = signal && extraSignal ? AbortSignal.any([signal, extraSignal]) : signal ?? extraSignal
|
|
try {
|
|
live(deadlineAt, combined)
|
|
return await metadata(deps, { deadlineAt: Math.min(deadlineAt, Date.now() + 10000), signal: combined }, async (sql, client, owner) => {
|
|
const validate = ({ connection, grantRows: payloads }: Awaited<ReturnType<typeof effectiveMcpConnectionSource>>, generation: string) => {
|
|
live(deadlineAt, combined)
|
|
if (payloads.length > 1 || connection.id !== principal.connectionId || connection.generation !== generation
|
|
|| connection.policy.status !== "active" || connection.policy.mode === "observer" || !connection.policy.scope.allowGlobal
|
|
|| !connection.policy.scope.operationIds.includes(SERVER_POLL_OPERATION)
|
|
|| !hasPermission(connection.owner_permissions, "mm:servers:write") || !hasPermission(connection.policy.permissions, "mm:servers:write")
|
|
|| Date.parse(connection.policy.expiresAt) <= Date.now() || connection.owner_valid_until.getTime() <= Date.now()) fail()
|
|
const allowed = ids(connection.policy.scope.serverIds)
|
|
live(deadlineAt, combined)
|
|
return { ownerId: connection.owner_id, ownerPermissions: [...connection.owner_permissions], policy: structuredClone(connection.policy), generation,
|
|
ownerValidUntil: connection.owner_valid_until.toISOString(), ownerClaimsIssuedAt: connection.owner_claims_issued_at.toISOString(), grants: payloads, allowed }
|
|
}
|
|
const read = async () => {
|
|
live(deadlineAt, combined)
|
|
const source = await deps.connectionSource(principal, client), generation = await deps.generation()
|
|
return validate(source, generation)
|
|
}
|
|
const poll = async (before: ReturnType<typeof validate>) => {
|
|
const all = await catalogue(sql, request.serverId, ids(affected))
|
|
if (all.some(id => !before.allowed.includes(id))) fail()
|
|
const refs = await protections(sql, true), after = await read()
|
|
if (fingerprint(before) !== fingerprint(after)) fail("STATE_CONFLICT", 409)
|
|
return grant(all, { transport: "mcp", principal, authority: before, serverIds: all, protections: refs })
|
|
}
|
|
if (runtimeBoundary) {
|
|
const bound = await readSynchronousPrincipalBoundary(principal, client, (packet: PrincipalBoundaryPacket) => {
|
|
const source = principalBoundaryProjection(packet, principal, mcpStateDirectory(), true)
|
|
const before = validate(source, source.generation)
|
|
return { result: coordinateRuntimePollPrincipalBoundary(runtimeBoundary, packet, { ...owner, client }, () => poll(before)) }
|
|
})
|
|
return await bound.result
|
|
}
|
|
return await poll(await read())
|
|
})
|
|
} catch (error) { return safe(error) }
|
|
}
|
|
}
|
|
|
|
function restAuthority(rawActor: AuthUser | undefined, context: ServerPollRestAuthorityContext, deps: Dependencies): ServerPollDependencies["authorize"] {
|
|
const acceptedAt = context.acceptedAt, requestId = identifier.parse(context.requestId), signal = context.signal, assertRequestActive = context.assertRequestActive
|
|
if (assertRequestActive !== undefined && typeof assertRequestActive !== "function") fail("INVALID_INPUT", 400)
|
|
assertRequestActive?.()
|
|
if (typeof context.authRequired !== "boolean" || !Number.isSafeInteger(acceptedAt) || acceptedAt <= 0 || acceptedAt > Date.now()) fail("INVALID_INPUT", 400)
|
|
const local = context.authRequired === false && rawActor === undefined
|
|
if (!local && (!rawActor || !identifier.safeParse(rawActor.id).success || !Array.isArray(rawActor.apps) || !rawActor.apps.includes("mm")
|
|
|| !Array.isArray(rawActor.permissions) || !hasPermission(rawActor.permissions, "mm:servers:write")
|
|
|| !Number.isSafeInteger(rawActor.issuedAt) || rawActor.issuedAt! <= 0 || rawActor.issuedAt! > Date.now()
|
|
|| !Number.isSafeInteger(rawActor.expiresAt) || rawActor.expiresAt! <= Date.now() || rawActor.expiresAt! <= rawActor.issuedAt!)) fail()
|
|
const actor = rawActor ? Object.freeze({ ...rawActor, apps: Object.freeze([...rawActor.apps]) as unknown as string[], permissions: Object.freeze([...rawActor.permissions]) as unknown as string[] }) : undefined
|
|
const deadlineAt = deadline(context, Math.min(acceptedAt + SERVER_POLL_WALL_BUDGET_MS, actor?.expiresAt ?? Infinity))
|
|
return async (request, affected, extraSignal) => {
|
|
const combined = signal && extraSignal ? AbortSignal.any([signal, extraSignal]) : signal ?? extraSignal
|
|
try {
|
|
live(deadlineAt, combined)
|
|
return await metadata(deps, { deadlineAt: Math.min(deadlineAt, Date.now() + 10000), signal: combined, assertRequestActive }, async (sql, client) => {
|
|
const read = async () => { live(deadlineAt, combined); if (actor) await deps.portal(actor, client); const generation = await deps.generation(); live(deadlineAt, combined); return generation }
|
|
const before = await read(), all = await catalogue(sql, request.serverId, ids(affected)), refs = await protections(sql, false), after = await read()
|
|
if (before !== after) fail("STATE_CONFLICT", 409)
|
|
return grant(all, { transport: "rest", requestId, acceptedAt, actor: actor ? { id: actor.id, issuedAt: actor.issuedAt, expiresAt: actor.expiresAt, permissions: actor.permissions } : { id: "local-operator" }, generation: before, serverIds: all, protections: refs })
|
|
})
|
|
} catch (error) { return safe(error) }
|
|
}
|
|
}
|
|
|
|
function construct<T>(work: () => T): T { try { return work() } catch (error) { return safe(error) } }
|
|
export const createMcpServerPollAuthority = (principal: McpPrincipal, options: ServerPollAuthorityOptions = {}) => construct(() => mcpAuthority(principal, options, production))
|
|
export const createRestServerPollAuthority = (actor: AuthUser | undefined, context: ServerPollRestAuthorityContext) => construct(() => restAuthority(actor, context, production))
|
|
/** Explicit trusted unit composition only, never deserialized from requests. */
|
|
export const createMcpServerPollAuthorityForTests = (principal: McpPrincipal, options: ServerPollAuthorityOptions, dependencies: Dependencies) => construct(() => mcpAuthority(principal, options, dependencies))
|
|
export const createRestServerPollAuthorityForTests = (actor: AuthUser | undefined, context: ServerPollRestAuthorityContext, dependencies: Dependencies) => construct(() => restAuthority(actor, context, dependencies))
|
|
|
|
/** Fixed production composition. Mismatch falls back before sharing; failures
|
|
* of a minted active-origin capability never downgrade into another path. */
|
|
const combinedRuntimeAuthorities = new WeakSet<ServerPollDependencies["authorize"]>()
|
|
export function isCombinedRuntimeMcpServerPollAuthority(value: unknown): value is ServerPollDependencies["authorize"] { return typeof value === "function" && combinedRuntimeAuthorities.has(value as ServerPollDependencies["authorize"]) }
|
|
export function createCombinedRuntimeMcpServerPollAuthority(principal: McpPrincipal, options: ServerPollAuthorityOptions = {}): ServerPollDependencies["authorize"] | undefined {
|
|
const token = captureRuntimePollPrincipalBoundary(principal, fixedPrincipalBoundarySource)
|
|
if (!token) return undefined
|
|
const authority = construct(() => mcpAuthority(principal, options, production, token))
|
|
combinedRuntimeAuthorities.add(authority)
|
|
return authority
|
|
}
|