147 lines
11 KiB
TypeScript
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)
|
|
}
|
|
}
|