import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import pg from 'pg'; import { assertDestructiveTestDatabase, describeDatabaseTarget, resolveDestructiveTestConfirmation, } from './lib/destructive-test-database-guard.js'; import { TENANT_FOREIGN_KEY_AUDIT_KIND, auditTenantForeignKeyData, loadTenantForeignKeyRelations, summarizeTenantForeignKeySchema, tenantForeignKeyExceptions, } from './lib/tenant-foreign-key-audit.js'; const { Client } = pg; const BLOCKED_TARGET_PATTERN = /(?:^|[-_.])tikupro(?:-pg)?(?:$|[-_.])/i; function argumentValue(argv, name) { const index = argv.indexOf(name); if (index >= 0) return String(argv[index + 1] || '').trim(); const prefix = `${name}=`; const item = argv.find(value => value.startsWith(prefix)); return item ? item.slice(prefix.length).trim() : ''; } export function parseTenantForeignKeyAuditOptions(argv = process.argv.slice(2), env = process.env) { const databaseUrl = String(env.DATABASE_URL || '').trim(); if (!databaseUrl) throw new Error('DATABASE_URL is required'); const timeoutValue = argumentValue(argv, '--statement-timeout-ms') || env.TENANT_FK_AUDIT_STATEMENT_TIMEOUT_MS || '120000'; const statementTimeoutMs = Number(timeoutValue); if (!Number.isInteger(statementTimeoutMs) || statementTimeoutMs < 1_000 || statementTimeoutMs > 900_000) { throw new Error('statement timeout must be an integer between 1000 and 900000 milliseconds'); } return { databaseUrl, statementTimeoutMs, confirmation: resolveDestructiveTestConfirmation(env, argv), json: argv.includes('--json'), quiet: argv.includes('--quiet'), writePath: argumentValue(argv, '--write'), }; } export function assertTenantForeignKeyAuditTarget(databaseUrl) { const target = describeDatabaseTarget(databaseUrl); const host = target.host.replace(/^\[(.*)\]$/, '$1').toLowerCase(); if (['127.0.0.1', 'localhost', '::1'].includes(host) && target.port === '5432') { throw new Error('Refusing tenant foreign key audit: local port 5432 is reserved for tikupro-pg'); } if ([target.host, target.database, target.user].some(value => BLOCKED_TARGET_PATTERN.test(value))) { throw new Error('Refusing tenant foreign key audit: tikupro-pg targets are forbidden'); } return target; } export async function runTenantForeignKeyAudit(options) { const startedAt = new Date(); const target = assertTenantForeignKeyAuditTarget(options.databaseUrl); const client = new Client({ connectionString: options.databaseUrl, application_name: 'tiku-tenant-foreign-key-audit', }); await client.connect(); try { const safety = await assertDestructiveTestDatabase({ client, databaseUrl: options.databaseUrl, confirmation: options.confirmation, operation: 'tenant foreign key full-data audit on an isolated clone', }); const relations = await loadTenantForeignKeyRelations(client); const schema = summarizeTenantForeignKeySchema(relations); const violations = schema.schemaMatches ? await auditTenantForeignKeyData(client, relations, options.statementTimeoutMs) : []; const completedAt = new Date(); return { schemaVersion: 1, kind: TENANT_FOREIGN_KEY_AUDIT_KIND, startedAt: startedAt.toISOString(), completedAt: completedAt.toISOString(), durationMs: completedAt.getTime() - startedAt.getTime(), target, safety: { databaseEnvironment: safety.environment }, schema, exceptions: tenantForeignKeyExceptions(), data: { auditedRelations: schema.schemaMatches ? relations.length : 0, invalidRelations: violations.length, violations, }, status: schema.schemaMatches && violations.length === 0 ? 'pass' : 'fail', }; } finally { await client.end(); } } async function main() { let options; try { options = parseTenantForeignKeyAuditOptions(); const artifact = await runTenantForeignKeyAudit(options); if (options.writePath) { const outputPath = path.resolve(process.cwd(), options.writePath); await fs.mkdir(path.dirname(outputPath), { recursive: true }); await fs.writeFile(outputPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8'); } if (options.json) console.log(JSON.stringify(artifact, null, 2)); else if (!options.quiet) { console.log(`Tenant foreign key audit: ${artifact.status.toUpperCase()}`); console.log(`Relations: ${artifact.schema.relationCount}; exceptions: ${artifact.schema.exceptionCount}; invalid: ${artifact.data.invalidRelations}`); if (options.writePath) console.log(`Artifact: ${path.resolve(process.cwd(), options.writePath)}`); } if (artifact.status !== 'pass') process.exitCode = 1; } catch (error) { const failure = { status: 'fail', error: error instanceof Error ? error.message : String(error) }; if (options?.json || process.argv.includes('--json')) console.log(JSON.stringify(failure, null, 2)); else console.error(failure.error); process.exitCode = 1; } } const currentFile = fileURLToPath(import.meta.url); if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) await main();