import { createHash } from 'node:crypto' import { spawnSync } from 'node:child_process' import { createReadStream, realpathSync } from 'node:fs' import { lstat, mkdir, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const MIGRATIONS_ROOT = path.join(ROOT, 'prisma', 'migrations') const BAKED_COMMIT_FILE = path.join(ROOT, 'DEPLOYMENT_COMMIT') const TARGET_MIGRATIONS = Object.freeze([ '20260811_add_video_transcode_lease', '20260811_update_brand_logo_defaults', '20260812_add_product_package_upload_job_lease', '20260812_align_material_upload_idempotency_index', '20260813_add_material_custody_department', '20260813_add_organization_units_and_employment_state', '20260813_add_user_employee_binding', '20260817_add_material_is_incomplete_shell', '20260820_add_ding_workflow_snapshots', '20260824_expand_ding_workflow_insights', '20260826_add_ding_workflow_demand_type', '20260827_add_ding_workflow_detail_fields', '20260826_add_pack_video_early_access', '20260828_add_pack_item_can_edit_assets', '20260828_add_task_detail_change_approvals', '20260829_add_recoverable_api_key_secret', ]) const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs' const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT' const VERIFIED_BACKUP_SUMMARY_TABLES = Object.freeze([ 'materials', 'users', 'systemSettings', 'prismaMigrations', 'productPackageUploadJobs', ]) const ISOLATED_DATABASE_PATTERN = /^gongxiangp2_restore_[a-z0-9][a-z0-9_]{7,55}$/ function fail(message) { throw new Error(message) } export function requireExactCommit(value, label) { const normalized = String(value || '').trim() if (!/^[0-9a-f]{40}$/.test(normalized)) { fail(`${label} must be an exact 40-character lowercase Git SHA`) } return normalized } export function sha256(buffer) { return createHash('sha256').update(buffer).digest('hex') } export function normalizeColumnDefault(value) { let normalized = String(value ?? '').trim().replace(/::(?:text|character varying)$/i, '') if (normalized.startsWith("'") && normalized.endsWith("'")) { normalized = normalized.slice(1, -1) } return normalized } function normalizedPort(value) { const port = String(value || '').trim() if (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) { fail('Database port must be an integer between 1 and 65535') } return String(Number(port)) } export function assertExactBtreeIndex(indexDefinition, expectedTable, expectedColumns) { const definition = String(indexDefinition || '').trim().replace(/\s+/g, ' ') if (!definition || /\bUNIQUE\b/i.test(definition) || /\bWHERE\b/i.test(definition)) { fail(`Database index must be a non-unique, non-partial btree index on ${expectedTable}`) } const match = definition.match( /^CREATE INDEX \S+ ON (?:ONLY )?(?:(?:"[^"]+"|[a-zA-Z_][\w$]*)\.)?("[^"]+"|[a-zA-Z_][\w$]*) USING ([a-zA-Z0-9_]+) \(([^()]*)\)$/i, ) if (!match) fail(`Database index definition is not a simple index on ${expectedTable}`) const table = match[1].replace(/^"|"$/g, '') const method = match[2].toLowerCase() const columns = match[3].split(',').map((part) => { const column = part.trim() const identifier = column.match(/^"?([a-zA-Z_][\w$]*)"?$/) if (!identifier) fail(`Database index contains an expression or sort modifier: ${column}`) return identifier[1] }) if ( table !== expectedTable || method !== 'btree' || columns.length !== expectedColumns.length || columns.some((column, index) => column !== expectedColumns[index]) ) { fail(`Database index columns or method mismatch: ${expectedTable}`) } } function normalizedPredicate(value) { return String(value || '') .replace(/[()\"]/g, '') .replace(/\s+/g, ' ') .trim() } export function assertExactActiveUploadIdIndex(index) { if ( !index || String(index.tablename || '') !== 'materials' || String(index.indexname || '') !== 'materials_uploadId_key' || index.indisunique !== true || index.indisprimary !== false || index.indisvalid !== true || index.indisready !== true || String(index.access_method || '') !== 'btree' || !Array.isArray(index.columns) || index.columns.length !== 1 || String(index.columns[0]) !== '"uploadId"' || normalizedPredicate(index.predicate) !== 'uploadId IS NOT NULL AND deletedAt IS NULL' ) { fail('Database index mismatch: materials_uploadId_key must be the exact active-only uploadId unique btree') } } async function sha256File(filePath) { const hash = createHash('sha256') await new Promise((resolve, reject) => { const stream = createReadStream(filePath) stream.on('data', (chunk) => hash.update(chunk)) stream.once('error', reject) stream.once('end', resolve) }) return hash.digest('hex') } export function databaseFingerprintHash(database) { return sha256(Buffer.from(JSON.stringify({ configuredHost: String(database.configuredHost || ''), configuredPort: String(database.configuredPort || ''), configuredDatabase: String(database.configuredDatabase || ''), configuredUser: String(database.configuredUser || ''), serverAddress: String(database.serverAddress || ''), serverPort: String(database.serverPort || ''), }))) } function stableJson(value) { if (Array.isArray(value)) return value.map(stableJson) if (value && typeof value === 'object') { return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableJson(value[key])])) } return value } export function verifiedBackupSummaryHash(summary) { return sha256(Buffer.from(JSON.stringify(stableJson(summary)))) } function assertBoundedBackupSummary(summary, label) { if ( !summary || typeof summary !== 'object' || Array.isArray(summary) || summary.schemaVersion !== 1 || summary.sampleLimit !== 32 ) { fail(`${label} must use the bounded backup summary schemaVersion 1`) } for (const table of VERIFIED_BACKUP_SUMMARY_TABLES) { const evidence = summary[table] if ( !evidence || typeof evidence !== 'object' || Array.isArray(evidence) || !/^(?:0|[1-9]\d*)$/.test(String(evidence.count || '')) || !/^[0-9a-f]{32}$/.test(String(evidence.sampleSha256InputMd5 || '')) ) { fail(`${label} is missing valid bounded evidence for ${table}`) } } } function assertIsolatedRestoreFingerprint(production, isolated) { const keys = [ 'configuredHost', 'configuredPort', 'configuredDatabase', 'configuredUser', 'serverAddress', 'serverPort', ] if ( !isolated || typeof isolated !== 'object' || Array.isArray(isolated) || keys.some((key) => !String(isolated[key] || '').trim()) || !ISOLATED_DATABASE_PATTERN.test(String(isolated.configuredDatabase)) ) { fail('Backup receipt must contain the exact isolated restore database fingerprint') } normalizedPort(isolated.configuredPort) normalizedPort(isolated.serverPort) if ( production.configuredDatabase === isolated.configuredDatabase || ( production.configuredHost === isolated.configuredHost && String(production.configuredPort) === String(isolated.configuredPort) ) || ( production.serverAddress === isolated.serverAddress && String(production.serverPort) === String(isolated.serverPort) ) ) { fail('Backup receipt isolated restore target is not distinct from production') } } function redactDatabaseFingerprint(url) { return { configuredHost: url.hostname, configuredPort: url.port || '5432', configuredDatabase: decodeURIComponent(url.pathname.replace(/^\//, '')), configuredUser: decodeURIComponent(url.username), } } async function loadLocalMigrations() { const entries = await readdir(MIGRATIONS_ROOT, { withFileTypes: true }) const result = new Map() for (const entry of entries) { if (!entry.isDirectory()) continue const sqlPath = path.join(MIGRATIONS_ROOT, entry.name, 'migration.sql') try { const sql = await readFile(sqlPath) result.set(entry.name, { sqlPath, checksum: sha256(sql) }) } catch { // Directories without migration.sql are not Prisma migrations. } } return result } async function queryEvidence(prisma, localMigrations) { const names = Array.from(localMigrations.keys()) const migrationRows = await prisma.$queryRawUnsafe( `SELECT migration_name, checksum, finished_at::text AS finished_at, rolled_back_at::text AS rolled_back_at, logs FROM "_prisma_migrations" WHERE migration_name = ANY($1::text[])`, names, ) const identityRows = await prisma.$queryRawUnsafe( `SELECT current_database() AS database, current_user AS "user", COALESCE(inet_server_addr()::text, '') AS server_address, COALESCE(inet_server_port()::text, '') AS server_port`, ) const columns = await prisma.$queryRawUnsafe( `SELECT table_name, column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_schema = current_schema() AND ((table_name = 'materials' AND column_name IN ('uploadId', 'transcode_lease_token', 'transcode_lease_expires_at')) OR (table_name = 'materials' AND column_name IN ('custody_department_id', 'custody_at', 'custody_reason', 'is_incomplete_shell')) OR (table_name = 'product_package_upload_jobs' AND column_name IN ('worker_id', 'lease_token', 'lease_expires_at')) OR (table_name = 'api_keys' AND column_name IN ('secret_ciphertext', 'secret_nonce', 'secret_auth_tag')) OR (table_name = 'api_key_reveal_audits' AND column_name IN ('id', 'api_key_id', 'viewer_user_id', 'created_at')) OR (table_name = 'system_settings' AND column_name IN ('headerLogoUrl', 'loginLogoUrl', 'logoUrl')) OR (table_name = 'organization_units' AND column_name IN ('id', 'source_dept_id', 'parent_id', 'path', 'level', 'is_active')) OR (table_name = 'performance_survey_employee_profiles' AND column_name IN ('employment_state', 'resigned_at', 'handover_dingtalk_user_id')) OR (table_name = 'user_employee_bindings' AND column_name IN ('id', 'user_id', 'employee_profile_id', 'binding_source', 'status')) OR (table_name = 'ding_workflow_snapshots' AND column_name IN ('process_instance_id', 'status', 'is_ongoing', 'source_updated_at_raw', 'scheme_field_count', 'task_count', 'active_task_count', 'synced_at', 'brand', 'demand_type', 'requester_name', 'requester_department', 'project_type', 'active_tasks', 'form_fields')) OR (table_name = 'ding_workflow_sync_states' AND column_name IN ('id', 'status', 'source_cursor_raw', 'last_completed_at', 'lease_owner', 'lease_expires_at', 'current_ongoing_count')) OR (table_name = 'task_detail_change_requests' AND column_name IN ('id', 'material_id', 'requester_user_id', 'manager_dingtalk_user_id', 'status', 'operations', 'base_material_updated_at', 'expires_at')) OR (table_name = 'task_detail_change_notifications' AND column_name IN ('id', 'request_id', 'event_type', 'business_key', 'status', 'next_retry_at'))) ORDER BY table_name, column_name`, ) const indexes = await prisma.$queryRawUnsafe( `SELECT tbl.relname AS tablename, idx.relname AS indexname, pg_get_indexdef(i.indexrelid) AS indexdef, i.indisunique, i.indisprimary, i.indisvalid, i.indisready, am.amname AS access_method, ARRAY( SELECT pg_get_indexdef(i.indexrelid, ordinal, true) FROM generate_series(1, i.indnkeyatts) AS ordinal ORDER BY ordinal ) AS columns, COALESCE(pg_get_expr(i.indpred, i.indrelid), '') AS predicate FROM pg_index i JOIN pg_class idx ON idx.oid = i.indexrelid JOIN pg_class tbl ON tbl.oid = i.indrelid JOIN pg_namespace ns ON ns.oid = tbl.relnamespace JOIN pg_am am ON am.oid = idx.relam WHERE ns.nspname = current_schema() AND idx.relname IN ( 'materials_transcode_lease_idx', 'materials_uploadId_key', 'materials_is_incomplete_shell_idx', 'product_package_upload_jobs_lease_idx', 'ding_workflow_snapshots_ongoing_updated_idx', 'ding_workflow_snapshots_brand_idx', 'ding_workflow_snapshots_demand_type_idx', 'ding_workflow_snapshots_requester_department_idx', 'ding_workflow_sync_states_lease_idx', 'task_detail_change_requests_status_expires_idx', 'task_detail_change_requests_status_updated_idx', 'task_detail_change_notifications_status_retry_idx', 'api_key_reveal_audits_api_key_id_created_at_idx', 'api_key_reveal_audits_viewer_user_id_created_at_idx' ) ORDER BY idx.relname`, ) const brandRows = await prisma.$queryRawUnsafe( `SELECT id, "headerLogoUrl", "loginLogoUrl" FROM "system_settings" ORDER BY id`, ) return { migrationRows, identity: identityRows[0], columns, indexes, brandRows } } function completedMigrationMap(rows) { const map = new Map() for (const row of rows) { if (!row.finished_at || row.rolled_back_at) continue map.set(String(row.migration_name), row) } return map } export function assertTargetMigrationEvidence(localMigrations, evidence) { const completed = completedMigrationMap(evidence.migrationRows) for (const name of TARGET_MIGRATIONS) { const local = localMigrations.get(name) if (!local) fail(`Required migration file is missing: ${name}`) const row = completed.get(name) if (!row) fail(`Required migration is not completed: ${name}`) if (String(row.checksum) !== local.checksum) { fail(`Database checksum differs from the exact source tree: ${name}`) } } const columnByKey = new Map(evidence.columns.map((row) => [ `${row.table_name}.${row.column_name}`, row, ])) const requireColumn = (table, column, type, nullable) => { const row = columnByKey.get(`${table}.${column}`) if (!row || row.data_type !== type || row.is_nullable !== nullable) { fail(`Database column mismatch: ${table}.${column}`) } } requireColumn('materials', 'transcode_lease_token', 'text', 'YES') requireColumn('materials', 'transcode_lease_expires_at', 'timestamp without time zone', 'YES') requireColumn('materials', 'uploadId', 'text', 'YES') requireColumn('api_keys', 'secret_ciphertext', 'text', 'YES') requireColumn('api_keys', 'secret_nonce', 'text', 'YES') requireColumn('api_keys', 'secret_auth_tag', 'text', 'YES') requireColumn('api_key_reveal_audits', 'id', 'text', 'NO') requireColumn('api_key_reveal_audits', 'api_key_id', 'text', 'NO') requireColumn('api_key_reveal_audits', 'viewer_user_id', 'text', 'NO') requireColumn('api_key_reveal_audits', 'created_at', 'timestamp without time zone', 'NO') requireColumn('task_detail_change_requests', 'id', 'text', 'NO') requireColumn('task_detail_change_requests', 'material_id', 'text', 'NO') requireColumn('task_detail_change_requests', 'requester_user_id', 'text', 'NO') requireColumn('task_detail_change_requests', 'manager_dingtalk_user_id', 'text', 'NO') requireColumn('task_detail_change_requests', 'status', 'text', 'NO') requireColumn('task_detail_change_requests', 'operations', 'jsonb', 'NO') requireColumn('task_detail_change_requests', 'base_material_updated_at', 'timestamp without time zone', 'NO') requireColumn('task_detail_change_requests', 'expires_at', 'timestamp without time zone', 'NO') requireColumn('task_detail_change_notifications', 'id', 'text', 'NO') requireColumn('task_detail_change_notifications', 'request_id', 'text', 'NO') requireColumn('task_detail_change_notifications', 'event_type', 'text', 'NO') requireColumn('task_detail_change_notifications', 'business_key', 'text', 'NO') requireColumn('task_detail_change_notifications', 'status', 'text', 'NO') requireColumn('task_detail_change_notifications', 'next_retry_at', 'timestamp without time zone', 'YES') requireColumn('materials', 'custody_department_id', 'text', 'YES') requireColumn('materials', 'custody_at', 'timestamp without time zone', 'YES') requireColumn('materials', 'custody_reason', 'text', 'YES') requireColumn('materials', 'is_incomplete_shell', 'boolean', 'NO') requireColumn('product_package_upload_jobs', 'worker_id', 'text', 'YES') requireColumn('product_package_upload_jobs', 'lease_token', 'text', 'YES') requireColumn('product_package_upload_jobs', 'lease_expires_at', 'timestamp without time zone', 'YES') requireColumn('organization_units', 'id', 'text', 'NO') requireColumn('organization_units', 'source_dept_id', 'integer', 'NO') requireColumn('organization_units', 'parent_id', 'text', 'YES') requireColumn('organization_units', 'path', 'text', 'NO') requireColumn('organization_units', 'level', 'integer', 'NO') requireColumn('organization_units', 'is_active', 'boolean', 'NO') requireColumn('performance_survey_employee_profiles', 'employment_state', 'text', 'YES') requireColumn('performance_survey_employee_profiles', 'resigned_at', 'timestamp without time zone', 'YES') requireColumn('performance_survey_employee_profiles', 'handover_dingtalk_user_id', 'text', 'YES') requireColumn('user_employee_bindings', 'id', 'text', 'NO') requireColumn('user_employee_bindings', 'user_id', 'text', 'NO') requireColumn('user_employee_bindings', 'employee_profile_id', 'text', 'NO') requireColumn('user_employee_bindings', 'binding_source', 'text', 'NO') requireColumn('user_employee_bindings', 'status', 'text', 'NO') requireColumn('ding_workflow_snapshots', 'process_instance_id', 'text', 'NO') requireColumn('ding_workflow_snapshots', 'status', 'text', 'NO') requireColumn('ding_workflow_snapshots', 'is_ongoing', 'boolean', 'NO') requireColumn('ding_workflow_snapshots', 'source_updated_at_raw', 'text', 'YES') requireColumn('ding_workflow_snapshots', 'scheme_field_count', 'integer', 'NO') requireColumn('ding_workflow_snapshots', 'task_count', 'integer', 'NO') requireColumn('ding_workflow_snapshots', 'active_task_count', 'integer', 'NO') requireColumn('ding_workflow_snapshots', 'synced_at', 'timestamp without time zone', 'NO') requireColumn('ding_workflow_snapshots', 'brand', 'text', 'YES') requireColumn('ding_workflow_snapshots', 'demand_type', 'text', 'YES') requireColumn('ding_workflow_snapshots', 'requester_name', 'text', 'YES') requireColumn('ding_workflow_snapshots', 'requester_department', 'text', 'YES') requireColumn('ding_workflow_snapshots', 'project_type', 'text', 'YES') requireColumn('ding_workflow_snapshots', 'active_tasks', 'jsonb', 'YES') requireColumn('ding_workflow_snapshots', 'form_fields', 'jsonb', 'YES') requireColumn('ding_workflow_sync_states', 'id', 'text', 'NO') requireColumn('ding_workflow_sync_states', 'status', 'text', 'NO') requireColumn('ding_workflow_sync_states', 'source_cursor_raw', 'text', 'YES') requireColumn('ding_workflow_sync_states', 'last_completed_at', 'timestamp without time zone', 'YES') requireColumn('ding_workflow_sync_states', 'lease_owner', 'text', 'YES') requireColumn('ding_workflow_sync_states', 'lease_expires_at', 'timestamp without time zone', 'YES') requireColumn('ding_workflow_sync_states', 'current_ongoing_count', 'integer', 'NO') requireColumn('system_settings', 'headerLogoUrl', 'text', 'NO') requireColumn('system_settings', 'loginLogoUrl', 'text', 'NO') const header = columnByKey.get('system_settings.headerLogoUrl') const login = columnByKey.get('system_settings.loginLogoUrl') const legacy = columnByKey.get('system_settings.logoUrl') if (!header || normalizeColumnDefault(header.column_default) !== '/brand/header-logo.png') { fail('Database default mismatch: system_settings.headerLogoUrl') } if (!login || normalizeColumnDefault(login.column_default) !== '/brand/login-logo.png') { fail('Database default mismatch: system_settings.loginLogoUrl') } if (legacy && (legacy.data_type !== 'text' || legacy.is_nullable !== 'NO')) { fail('Database column mismatch: system_settings.logoUrl') } if (legacy && normalizeColumnDefault(legacy.column_default) !== '/brand/header-logo.png') { fail('Database default mismatch: system_settings.logoUrl') } const indexByName = new Map(evidence.indexes.map((row) => [String(row.indexname), String(row.indexdef)])) assertExactBtreeIndex( indexByName.get('materials_transcode_lease_idx'), 'materials', ['transcode_lease_expires_at', 'transcode_lease_token'], ) assertExactBtreeIndex( indexByName.get('product_package_upload_jobs_lease_idx'), 'product_package_upload_jobs', ['status', 'lease_expires_at'], ) assertExactActiveUploadIdIndex( evidence.indexes.find((row) => String(row.indexname) === 'materials_uploadId_key'), ) assertExactBtreeIndex( indexByName.get('materials_is_incomplete_shell_idx'), 'materials', ['is_incomplete_shell'], ) assertExactBtreeIndex( indexByName.get('ding_workflow_snapshots_ongoing_updated_idx'), 'ding_workflow_snapshots', ['is_ongoing', 'source_updated_at_raw'], ) assertExactBtreeIndex( indexByName.get('ding_workflow_snapshots_brand_idx'), 'ding_workflow_snapshots', ['brand'], ) assertExactBtreeIndex( indexByName.get('ding_workflow_snapshots_demand_type_idx'), 'ding_workflow_snapshots', ['demand_type'], ) assertExactBtreeIndex( indexByName.get('ding_workflow_snapshots_requester_department_idx'), 'ding_workflow_snapshots', ['requester_department'], ) assertExactBtreeIndex( indexByName.get('ding_workflow_sync_states_lease_idx'), 'ding_workflow_sync_states', ['lease_expires_at'], ) assertExactBtreeIndex( indexByName.get('task_detail_change_requests_status_expires_idx'), 'task_detail_change_requests', ['status', 'expires_at'], ) assertExactBtreeIndex( indexByName.get('task_detail_change_requests_status_updated_idx'), 'task_detail_change_requests', ['status', 'updated_at'], ) assertExactBtreeIndex( indexByName.get('task_detail_change_notifications_status_retry_idx'), 'task_detail_change_notifications', ['status', 'next_retry_at'], ) assertExactBtreeIndex( indexByName.get('api_key_reveal_audits_api_key_id_created_at_idx'), 'api_key_reveal_audits', ['api_key_id', 'created_at'], ) assertExactBtreeIndex( indexByName.get('api_key_reveal_audits_viewer_user_id_created_at_idx'), 'api_key_reveal_audits', ['viewer_user_id', 'created_at'], ) const isTrustedBrandValue = (value, localDefault) => { const normalized = String(value || '').trim() if (normalized === localDefault) return true try { const parsed = new URL(normalized) return parsed.protocol === 'https:' && parsed.hostname === 's3.jiansun.vip' && parsed.port === '' && parsed.username === '' && parsed.password === '' && parsed.pathname.startsWith('/gongxiangpan/') && parsed.search === '' && parsed.hash === '' } catch { return false } } for (const row of evidence.brandRows || []) { if (!isTrustedBrandValue(row.headerLogoUrl, '/brand/header-logo.png')) { fail(`Stored brand logo is untrusted: system_settings.${row.id}.headerLogoUrl`) } if (!isTrustedBrandValue(row.loginLogoUrl, '/brand/login-logo.png')) { fail(`Stored brand logo is untrusted: system_settings.${row.id}.loginLogoUrl`) } } } function assertNoUnexpectedPendingMigrations(localMigrations, evidence) { const completed = completedMigrationMap(evidence.migrationRows) const targetSet = new Set(TARGET_MIGRATIONS) const unexpected = Array.from(localMigrations.keys()).filter( (name) => !completed.has(name) && !targetSet.has(name), ) if (unexpected.length > 0) { fail(`Unexpected pending migrations exist; refusing a bundled production write: ${unexpected.join(', ')}`) } const failed = evidence.migrationRows.filter((row) => !row.finished_at && !row.rolled_back_at) if (failed.length > 0) { fail(`Unresolved failed migrations exist: ${failed.map((row) => row.migration_name).join(', ')}`) } } export function assertExpectedDatabaseIdentity(databaseUrl, identity) { const expected = { configuredHost: String(process.env.EXPECTED_DATABASE_HOST || '').trim(), configuredPort: String(process.env.EXPECTED_DATABASE_PORT || '').trim(), configuredDatabase: String(process.env.EXPECTED_DATABASE_NAME || '').trim(), configuredUser: String(process.env.EXPECTED_DATABASE_USER || '').trim(), serverAddress: String(process.env.EXPECTED_DATABASE_SERVER_ADDRESS || '').trim(), serverPort: String(process.env.EXPECTED_DATABASE_SERVER_PORT || '').trim(), } if (Object.values(expected).some((value) => !value)) { fail('Apply mode requires the configured and server database host, port, name and user fingerprint') } expected.configuredPort = normalizedPort(expected.configuredPort) expected.serverPort = normalizedPort(expected.serverPort) const configured = redactDatabaseFingerprint(databaseUrl) if ( configured.configuredHost !== expected.configuredHost || configured.configuredPort !== expected.configuredPort || configured.configuredDatabase !== expected.configuredDatabase || configured.configuredUser !== expected.configuredUser || String(identity.database) !== expected.configuredDatabase || String(identity.user) !== expected.configuredUser || String(identity.server_address) !== expected.serverAddress || normalizedPort(identity.server_port) !== expected.serverPort ) { fail('Production database identity does not match the explicit expected fingerprint') } return { ...configured, serverAddress: expected.serverAddress, serverPort: expected.serverPort } } export function assertBackupReceiptDocument(receipt, context, now = new Date()) { if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt) || receipt.schemaVersion !== 1) { fail('Backup receipt must use schemaVersion 1') } const maxAgeMinutes = Number(context.maxAgeMinutes) if (!Number.isInteger(maxAgeMinutes) || maxAgeMinutes < 1 || maxAgeMinutes > 1440) { fail('BACKUP_MAX_AGE_MINUTES must be an integer from 1 to 1440') } if (receipt.sourceCommit !== context.expectedCommit) { fail('Backup receipt sourceCommit does not match the migrator commit') } if (receipt.generatedBy !== VERIFIED_BACKUP_GENERATOR) { fail('Backup receipt must be machine-generated by the verified backup helper') } const database = receipt.database const expectedDatabase = context.database if (!database || typeof database !== 'object' || [ 'configuredHost', 'configuredPort', 'configuredDatabase', 'configuredUser', 'serverAddress', 'serverPort', ].some((key) => String(database[key] || '') !== String(expectedDatabase[key] || ''))) { fail('Backup receipt database fingerprint does not match the verified production database') } const backup = receipt.backup const restore = receipt.restoreValidation if (!backup || typeof backup !== 'object' || !restore || typeof restore !== 'object') { fail('Backup receipt must contain backup and restoreValidation evidence') } if (backup.format !== 'pg_dump-custom') { fail('Backup receipt format must be pg_dump-custom') } if (!path.isAbsolute(String(backup.artifactPath || ''))) { fail('Backup artifactPath must be absolute') } if (!Number.isSafeInteger(backup.artifactSize) || backup.artifactSize <= 0) { fail('Backup artifactSize must be a positive integer') } if (!/^[0-9a-f]{64}$/.test(String(backup.artifactSha256 || ''))) { fail('Backup artifactSha256 must be a lowercase SHA-256') } if ( restore.status !== 'passed' || restore.artifactSha256 !== backup.artifactSha256 || restore.databaseFingerprint !== databaseFingerprintHash(database) ) { fail('Backup receipt does not prove a restore validation for this artifact and database fingerprint') } const isolated = restore.isolatedDatabase assertIsolatedRestoreFingerprint(database, isolated) if (restore.isolatedDatabaseFingerprint !== databaseFingerprintHash(isolated)) { fail('Backup receipt isolated restore database fingerprint hash mismatch') } const summary = restore.verificationSummary if ( !summary || typeof summary !== 'object' || Array.isArray(summary) || summary.schemaVersion !== 1 || summary.maintenanceWindowConfirmation !== VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION || summary.statementTimeoutMs !== 30_000 || summary.sampleLimit !== 32 ) { fail('Backup receipt must contain machine-generated bounded restore verification evidence') } assertBoundedBackupSummary(summary.sourceBefore, 'Production pre-dump summary') assertBoundedBackupSummary(summary.sourceAfter, 'Production post-dump summary') assertBoundedBackupSummary(summary.restored, 'Isolated restore summary') const sourceBeforeHash = verifiedBackupSummaryHash(summary.sourceBefore) const sourceAfterHash = verifiedBackupSummaryHash(summary.sourceAfter) const restoredHash = verifiedBackupSummaryHash(summary.restored) if ( sourceBeforeHash !== sourceAfterHash || sourceAfterHash !== restoredHash || summary.sourceSummarySha256 !== sourceAfterHash || summary.restoredSummarySha256 !== restoredHash ) { fail('Backup receipt bounded source and restore verification summaries do not match') } const startedAt = new Date(backup.startedAt) const completedAt = new Date(backup.completedAt) const restoreStartedAt = new Date(restore.startedAt) const restoreCompletedAt = new Date(restore.completedAt) if ( !Number.isFinite(startedAt.getTime()) || !Number.isFinite(completedAt.getTime()) || !Number.isFinite(restoreStartedAt.getTime()) || !Number.isFinite(restoreCompletedAt.getTime()) ) { fail('Backup and restore validation timestamps must be valid ISO timestamps') } const futureToleranceMs = 5 * 60 * 1000 if ( completedAt.getTime() > now.getTime() + futureToleranceMs || restoreCompletedAt.getTime() > now.getTime() + futureToleranceMs || completedAt.getTime() < startedAt.getTime() || restoreStartedAt.getTime() < completedAt.getTime() || restoreCompletedAt.getTime() < restoreStartedAt.getTime() || restoreCompletedAt.getTime() < completedAt.getTime() || now.getTime() - completedAt.getTime() > maxAgeMinutes * 60 * 1000 ) { fail('Backup receipt is stale or has an invalid validation timeline') } return { backup, restore } } async function assertBackupReceipt(context) { const receiptPathInput = String(process.env.BACKUP_RECEIPT_PATH || '').trim() const artifactRootInput = String(process.env.BACKUP_ARTIFACT_ROOT || '').trim() const receiptPath = receiptPathInput ? path.resolve(receiptPathInput) : '' const expectedHash = String(process.env.EXPECTED_BACKUP_RECEIPT_SHA256 || '').trim().toLowerCase() const artifactRoot = artifactRootInput ? path.resolve(artifactRootInput) : '' const maxAgeMinutes = String(process.env.BACKUP_MAX_AGE_MINUTES || '').trim() if (!receiptPath || !artifactRoot || !/^[0-9a-f]{64}$/.test(expectedHash) || !maxAgeMinutes) { fail('Apply mode requires the backup receipt hash, artifact root and freshness limit') } const stat = await lstat(receiptPath) if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0) { fail('Backup receipt must be a non-empty regular file') } const receiptBytes = await readFile(receiptPath) const actualHash = sha256(receiptBytes) if (actualHash !== expectedHash) fail('Backup receipt checksum mismatch') let receipt try { receipt = JSON.parse(receiptBytes.toString('utf8')) } catch { fail('Backup receipt must be valid JSON') } const { backup, restore } = assertBackupReceiptDocument(receipt, { ...context, maxAgeMinutes, }) const artifactPath = path.resolve(backup.artifactPath) const relativeArtifact = path.relative(artifactRoot, artifactPath) if (!relativeArtifact || relativeArtifact.startsWith('..') || path.isAbsolute(relativeArtifact)) { fail('Backup artifact must be a file below BACKUP_ARTIFACT_ROOT') } const artifactStat = await lstat(artifactPath) if (!artifactStat.isFile() || artifactStat.isSymbolicLink() || artifactStat.size !== backup.artifactSize) { fail('Backup artifact is missing, unsafe or has an unexpected size') } const artifactHash = await sha256File(artifactPath) if (artifactHash !== backup.artifactSha256) fail('Backup artifact checksum mismatch') return { receiptPath, receiptSha256: actualHash, receiptSize: stat.size, artifactPath, artifactSha256: artifactHash, artifactSize: artifactStat.size, backupCompletedAt: backup.completedAt, restoreValidatedAt: restore.completedAt, } } async function writeAuditRecord(record) { const outputPath = String(process.env.MIGRATION_AUDIT_OUTPUT || '').trim() if (!outputPath) { process.stdout.write(`${JSON.stringify(record, null, 2)}\n`) return } const resolved = path.resolve(outputPath) await mkdir(path.dirname(resolved), { recursive: true, mode: 0o750 }) const temp = `${resolved}.tmp-${process.pid}-${Date.now()}` await writeFile(temp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: 'wx' }) await rename(temp, resolved) } async function assertApplyAuditOutputWritable() { const outputInput = String(process.env.MIGRATION_AUDIT_OUTPUT || '').trim() if (!outputInput) fail('Apply mode requires MIGRATION_AUDIT_OUTPUT') const outputPath = path.resolve(outputInput) try { await lstat(outputPath) fail('MIGRATION_AUDIT_OUTPUT already exists; use a new immutable audit filename') } catch (error) { if (error?.code !== 'ENOENT') throw error } const parent = path.dirname(outputPath) const parentStat = await lstat(parent) if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) { fail('Migration audit output parent must be a real directory') } const suffix = `.write-probe-${process.pid}-${Date.now()}` const source = path.join(parent, suffix) const destination = `${source}.renamed` try { await writeFile(source, 'audit-write-probe\n', { mode: 0o600, flag: 'wx' }) await rename(source, destination) } finally { await unlink(source).catch((error) => { if (error?.code !== 'ENOENT') throw error }) await unlink(destination).catch((error) => { if (error?.code !== 'ENOENT') throw error }) } } async function main() { const userArgs = process.argv.slice(2) const apply = userArgs.includes('--apply') if (userArgs.some((arg) => !['--apply', '--audit'].includes(arg))) { fail('Only --audit (default) or --apply is supported') } if (apply && userArgs.includes('--audit')) fail('Choose exactly one mode') const bakedCommit = requireExactCommit(await readFile(BAKED_COMMIT_FILE, 'utf8'), 'Baked commit') const expectedCommit = requireExactCommit(process.env.EXPECTED_COMMIT_SHA, 'EXPECTED_COMMIT_SHA') if (bakedCommit !== expectedCommit) fail('Migrator image commit does not match EXPECTED_COMMIT_SHA') if (!process.env.DATABASE_URL) fail('DATABASE_URL is required') const databaseUrl = new URL(process.env.DATABASE_URL) if (databaseUrl.protocol !== 'postgresql:' && databaseUrl.protocol !== 'postgres:') { fail('DATABASE_URL must use PostgreSQL') } const localMigrations = await loadLocalMigrations() const { PrismaClient } = await import('@prisma/client') const prisma = new PrismaClient() let before let backup = null let verifiedDatabaseFingerprint = null try { before = await queryEvidence(prisma, localMigrations) assertNoUnexpectedPendingMigrations(localMigrations, before) if (apply) { if (process.env.ALLOW_PRODUCTION_MIGRATIONS !== 'APPLY') { fail('Apply mode requires ALLOW_PRODUCTION_MIGRATIONS=APPLY') } await assertApplyAuditOutputWritable() verifiedDatabaseFingerprint = assertExpectedDatabaseIdentity(databaseUrl, before.identity) backup = await assertBackupReceipt({ expectedCommit, database: verifiedDatabaseFingerprint, }) } } finally { await prisma.$disconnect() } if (apply) { const prismaCli = path.join(ROOT, 'node_modules', '.bin', 'prisma') const result = spawnSync( prismaCli, ['migrate', 'deploy', '--schema', path.join(ROOT, 'prisma', 'schema.prisma')], { cwd: ROOT, env: process.env, stdio: 'inherit', shell: process.platform === 'win32' }, ) if (result.error) throw result.error if (result.status !== 0) fail(`prisma migrate deploy failed with exit code ${result.status}`) } const verifyClient = new PrismaClient() let after try { after = await queryEvidence(verifyClient, localMigrations) if (apply) { const postApplyDatabaseFingerprint = assertExpectedDatabaseIdentity(databaseUrl, after.identity) if (databaseFingerprintHash(postApplyDatabaseFingerprint) !== databaseFingerprintHash(verifiedDatabaseFingerprint)) { fail('Production database identity changed while migrations were being applied') } } if (apply || TARGET_MIGRATIONS.every((name) => completedMigrationMap(after.migrationRows).has(name))) { assertTargetMigrationEvidence(localMigrations, after) } } finally { await verifyClient.$disconnect() } const completed = completedMigrationMap(after.migrationRows) const record = { mode: apply ? 'apply' : 'audit', timestamp: new Date().toISOString(), sourceCommit: bakedCommit, database: { ...redactDatabaseFingerprint(databaseUrl), actualDatabase: String(after.identity.database || ''), actualUser: String(after.identity.user || ''), serverAddress: String(after.identity.server_address || ''), serverPort: String(after.identity.server_port || ''), }, backup, targetMigrations: TARGET_MIGRATIONS.map((name) => ({ name, sourceChecksum: localMigrations.get(name)?.checksum || null, databaseChecksum: completed.get(name)?.checksum || null, completed: completed.has(name), })), verified: apply || TARGET_MIGRATIONS.every((name) => completed.has(name)), } await writeAuditRecord(record) if (apply) process.stdout.write('Production migrations applied and verified.\n') else process.stdout.write('Read-only production migration audit completed.\n') } if ( process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) ) { main().catch((error) => { process.stderr.write(`[SAFE_MIGRATION_ERROR] ${error instanceof Error ? error.message : String(error)}\n`) process.exitCode = 1 }) }