Files
MikrotikManager/backend/src/db/controlled-schema-reference.ts
T

147 lines
11 KiB
TypeScript

import { randomUUID } from "node:crypto"
import { mkdir, open, readFile, readdir, lstat } from "node:fs/promises"
import path from "node:path"
import { Pool, type PoolClient } from "pg"
import { env } from "../config.js"
import { fingerprint, mcpStateDirectory, privateSecretFingerprint } from "../modules/mcp/security.js"
import { analyzeMigration, MIGRATION_MANIFEST, migrationManifestDigest, validateMigrationFileSet } from "./migration-manifest.js"
import { captureMigrationSchema, compareMigrationSchemas, type CapturedMigrationSchema } from "./migration-schema-inventory.js"
const maximum = 32 * 1048576, domain = "database-controlled-schema-reference-v1"
const filename = () => path.join(mcpStateDirectory(), "database-controlled-schema-reference-v1.json")
export class ControlledSchemaReferenceProvisioningError extends Error {
readonly automaticRetry = false
constructor(readonly referenceDatabase: string, readonly phase: string, readonly publicationAttempted: boolean, options: ErrorOptions) {
super("Controlled reference provisioning unconfirmed; inspect the exact owned database before manual cleanup", options)
}
}
const scope = { applicationNamespaces: ["public"], excludedControlNamespaces: ["mmapp_control"] }
const refuse = (reason: string): never => { throw new Error("Controlled schema reference refused: " + reason) }
const sha = (value: unknown) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value)
const identifier = (value: string) => '"' + value.replaceAll('"', '""') + '"'
const literal = (value: string) => "'" + value.replaceAll("'", "''") + "'"
interface Envelope {
format: "controlled-schema-reference-v1"; manifestSha256: string; prefixLength: number; profileSha256: string;
schema: CapturedMigrationSchema; canonicalSha256: string; signature: string;
}
const profile = (schema: CapturedMigrationSchema) => fingerprint({ serverVersion: schema.serverVersion, scope: schema.scope, database: schema.sections.database, namespaces: schema.sections.namespaces })
/** Fixed path and fixed issuer only. Supplied JSON or a digest never becomes a
* reference. This proves provenance of a generated DDL reference, not data. */
export async function readControlledSchemaReference(): Promise<CapturedMigrationSchema | null> {
let handle
try { handle = await open(filename(), "r") }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error }
let text: string
try {
const stat = await handle.stat()
if (!stat.isFile() || stat.size < 1 || stat.size > maximum) return refuse("artifact bound")
const bytes = Buffer.alloc(stat.size + 1); let length = 0
while (length < bytes.length) { const read = await handle.read(bytes, length, bytes.length - length, length); if (!read.bytesRead) break; length += read.bytesRead }
if (length !== stat.size) return refuse("artifact changed while reading")
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length))
} finally { await handle.close() }
const value = JSON.parse(text) as Envelope
if (!value || Object.getPrototypeOf(value) !== Object.prototype || Object.keys(value).sort().join(" ") !== "canonicalSha256 format manifestSha256 prefixLength profileSha256 schema signature" ||
value.format !== "controlled-schema-reference-v1" || value.manifestSha256 !== migrationManifestDigest() || value.prefixLength !== MIGRATION_MANIFEST.length || !sha(value.profileSha256) || !sha(value.canonicalSha256) || !sha(value.signature)) return refuse("artifact envelope")
compareMigrationSchemas(value.schema, value.schema)
if (JSON.stringify(value.schema.scope) !== JSON.stringify(scope) || value.profileSha256 !== profile(value.schema)) return refuse("artifact profile")
const { signature, canonicalSha256, ...payload } = value
if (fingerprint(payload) !== canonicalSha256 || signature !== await privateSecretFingerprint(canonicalSha256, domain)) return refuse("artifact issuer proof")
return structuredClone(value.schema)
}
async function snapshot(client: PoolClient): Promise<CapturedMigrationSchema> {
await client.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY")
try {
await client.query("SET LOCAL search_path=pg_catalog")
await client.query("SET LOCAL TimeZone='UTC'")
await client.query("SET LOCAL DateStyle='ISO, YMD'")
await client.query("SET LOCAL statement_timeout='5s'")
const result = await captureMigrationSchema(client, { scope })
await client.query("ROLLBACK"); return result
} catch (error) { await client.query("ROLLBACK").catch(() => {}); throw error }
}
const databaseProfileSql = `SELECT current_user AS role,pg_catalog.current_setting('server_version_num') AS version,
pg_catalog.pg_encoding_to_char(d.encoding) AS encoding,d.datcollate AS collate,d.datctype AS ctype,d.datlocprovider::text AS provider,
d.datlocale AS locale,d.daticurules AS rules,d.datcollversion AS collation_version,ts.spcname AS tablespace,
d.datacl IS NULL AS default_acl,pg_catalog.shobj_description(d.oid,'pg_database') IS NULL AS no_comment,
pg_catalog.pg_get_userbyid(d.datdba)=current_user AS owned_by_current
FROM pg_catalog.pg_database d JOIN pg_catalog.pg_tablespace ts ON ts.oid=d.dattablespace WHERE d.datname=current_database()`
/** Separate explicit provisioning workflow. Creates ONLY its own fresh UUID
* database from template0, executes fixed pinned source there, closes it and
* drops exactly that verified database BEFORE publishing an exclusive artifact.
* No application data, application schema or control rows are changed.
* This is never called by startup, restore, imports or a public MCP tool. */
export async function provisionControlledSchemaReference() {
try { await lstat(filename()); return refuse("artifact already exists") }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error }
const directory = new URL("../../drizzle/", import.meta.url)
const entries = (await readdir(directory, { withFileTypes: true })).filter(entry => entry.name.toLowerCase().endsWith(".sql"))
if (entries.some(entry => !entry.isFile())) return refuse("non-file migration source")
validateMigrationFileSet(entries.map(entry => entry.name))
const files = await Promise.all(MIGRATION_MANIFEST.map(async pin => { const bytes = await readFile(new URL(pin.file, directory)); analyzeMigration(pin.file, bytes, pin); return { pin, sql: new TextDecoder("utf-8", { fatal: true }).decode(bytes) } }))
const { pool } = await import("./index.js"), control = await pool.connect()
const name = "mm_schema_reference_" + randomUUID().replaceAll("-", "")
if (!/^mm_schema_reference_[a-f0-9]{32}$/.test(name)) return refuse("owned reference name")
let reference: Pool | undefined, captured: CapturedMigrationSchema | undefined
let phase = "profile", publicationAttempted = false
try {
await control.query("SET standard_conforming_strings=on")
await control.query("SET statement_timeout='120s'")
const rows = (await control.query(databaseProfileSql)).rows
if (rows.length !== 1) return refuse("database profile missing")
const meta = rows[0]
if (!/^18\d{4}$/.test(meta.version) || !["b", "c", "i"].includes(meta.provider) || !meta.default_acl || !meta.no_comment || !meta.owned_by_current ||
[meta.role, meta.encoding, meta.collate, meta.ctype, meta.tablespace].some(value => typeof value !== "string" || !value || value.length > 256 || /[\x00-\x1f\x7f]/.test(value)) ||
[meta.locale, meta.rules, meta.collation_version].some(value => value !== null && (typeof value !== "string" || value.length > 4096 || /[\x00-\x1f\x7f]/.test(value)))) return refuse("unsupported database profile")
const provider = { b: "builtin", c: "libc", i: "icu" }[meta.provider as "b" | "c" | "i"]
let sql = `CREATE DATABASE ${identifier(name)} TEMPLATE template0 OWNER ${identifier(meta.role)} ENCODING ${literal(meta.encoding)} LOCALE_PROVIDER ${provider} LC_COLLATE ${literal(meta.collate)} LC_CTYPE ${literal(meta.ctype)} TABLESPACE ${identifier(meta.tablespace)}`
if (meta.provider === "b" || meta.provider === "i") { if (!meta.locale) return refuse("provider locale missing"); sql += ` ${meta.provider === "b" ? "BUILTIN_LOCALE" : "ICU_LOCALE"} ${literal(meta.locale)}` }
if (meta.provider === "i" && meta.rules !== null) sql += " ICU_RULES " + literal(meta.rules)
phase = "create-unknown"
await control.query(sql); phase = "created"
const url = new URL(env.DATABASE_URL); url.pathname = "/" + name
reference = new Pool({ connectionString: url.toString(), max: 1, connectionTimeoutMillis: 8000, statement_timeout: 120000 })
const client = await reference.connect()
let clean = false
try {
if (fingerprint((await client.query(databaseProfileSql)).rows[0]) !== fingerprint(meta)) return refuse("generated database profile mismatch")
await client.query("SET search_path=public,pg_catalog")
await client.query("CREATE SCHEMA mmapp_control")
for (const { pin, sql } of files) {
if (pin.transaction === "owner-transaction") await client.query("BEGIN")
const executed = await client.query(sql)
if (!pin.selfMarker) await client.query("INSERT INTO public.schema_migrations(id) VALUES($1)", [pin.file.slice(0, -4)])
const committed = pin.transaction === "owner-transaction" ? await client.query("COMMIT") : Array.isArray(executed) ? executed.at(-1) : executed
if (committed?.command !== "COMMIT") return refuse("reference file COMMIT not acknowledged")
}
const markers = (await client.query(`SELECT id FROM public.schema_migrations ORDER BY id LIMIT ${MIGRATION_MANIFEST.length + 1}`)).rows
if (fingerprint(markers) !== fingerprint(MIGRATION_MANIFEST.map(pin => ({ id: pin.file.slice(0, -4) })))) return refuse("reference markers mismatch")
captured = await snapshot(client); clean = true
} finally { client.release(!clean) }
await reference.end(); reference = undefined
const owned = (await control.query("SELECT datname,pg_catalog.pg_get_userbyid(datdba)=current_user AS owned FROM pg_catalog.pg_database WHERE datname=$1", [name])).rows
if (owned.length !== 1 || owned[0].datname !== name || owned[0].owned !== true) return refuse("reference cleanup ownership changed")
phase = "drop-unknown"
await control.query("DROP DATABASE " + identifier(name)); phase = "dropped"
const payload = { format: "controlled-schema-reference-v1" as const, manifestSha256: migrationManifestDigest(), prefixLength: MIGRATION_MANIFEST.length, profileSha256: profile(captured), schema: captured }
const canonicalSha256 = fingerprint(payload), signature = await privateSecretFingerprint(canonicalSha256, domain)
const bytes = Buffer.from(JSON.stringify({ ...payload, canonicalSha256, signature }) + "\n")
if (bytes.length > maximum) return refuse("generated artifact too large")
await mkdir(mcpStateDirectory(), { recursive: true, mode: 0o700 })
publicationAttempted = true
const output = await open(filename(), "wx", 0o600)
try { await output.writeFile(bytes); await output.sync() } finally { await output.close() }
return Object.freeze({ manifestSha256: payload.manifestSha256, logicalSchemaSha256: captured.sha256, referenceDatabaseDropped: true, readyAuthorized: false })
} catch (cause) {
throw new ControlledSchemaReferenceProvisioningError(name, phase, publicationAttempted, { cause })
} finally {
if (reference) await reference.end().catch(() => {})
// A failed or uncertain CREATE/DROP is never retried or force-dropped. The
// exact owned UUID may remain for an operator; no artifact is published.
control.release(true)
}
}