import fs from 'node:fs'; import path from 'node:path'; import vm from 'node:vm'; import { createHash } from 'node:crypto'; import { fileURLToPath, pathToFileURL } from 'node:url'; import ts from 'typescript'; import { teacherEarningsSupport } from './teacher-earnings-transform.mjs'; // Builds reviewable private artifacts only. No credentials, network, SQL or deployment. export const MOBILE_HELPERS = Object.freeze(['appCompanionOverview', 'appAccountOverview', 'appLiveScopeVersion', 'appReviewPage']); export const MONTHLY_HELPERS = Object.freeze(['teacherEarningsCalculator', 'teacherEarningsSourceRows', 'teacherEarningsInputs', 'teacherPrepareEarnings', 'teacherCalculatePreparedEarnings', 'teacherLessonEarnings', 'teacherEarningsFingerprint', 'teacherMonthlyEarningsInputs', 'teacherMonthlyLessonEarnings', 'teacherIncomeSummary']); export const OPERATIONS_HELPERS = Object.freeze(['payrollSyncStatusFromCutoff', 'payrollSettlementSyncStatus', 'payrollFreshReadSnapshot', 'payrollReadUsers', 'payrollCanonicalSnapshot', 'payrollPreviewUncached']); const sha256 = value => createHash('sha256').update(value).digest('hex'); const parse = code => ts.createSourceFile('gateway.js', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS); const named = (source, name) => source.statements.filter(node => ts.isFunctionDeclaration(node) && node.name?.text === name); export function patchFunctions(baseline, candidate, names) { if (new Set(names).size !== names.length) throw Error('Duplicate helper in allowlist'); const before = parse(baseline), local = parse(candidate), replacements = [], additions = [], audit = []; for (const name of names) { const existing = named(before, name), desired = named(local, name); if (existing.length > 1 || desired.length !== 1) throw Error(`Expected unique helper ${name}: baseline=${existing.length}, candidate=${desired.length}`); const replacement = desired[0].getText(local), old = existing[0]?.getText(before); if (old === replacement) { audit.push({ name, action: 'unchanged', hash: sha256(replacement) }); continue; } if (existing.length) replacements.push({ start: existing[0].getStart(before), end: existing[0].end, replacement }); else additions.push(replacement); audit.push({ name, action: existing.length ? 'replace' : 'add', beforeHash: old ? sha256(old) : null, afterHash: sha256(replacement) }); } let code = baseline; for (const edit of replacements.sort((a, b) => b.start - a.start)) code = code.slice(0, edit.start) + edit.replacement + code.slice(edit.end); if (additions.length) code += '\n\n' + additions.join('\n\n') + '\n'; const after = parse(code), allowed = new Set(names); const unselected = source => source.statements.filter(node => !(ts.isFunctionDeclaration(node) && allowed.has(node.name?.text))).map(node => node.getText(source)); const oldRest = JSON.stringify(unselected(before)), newRest = JSON.stringify(unselected(after)); if (oldRest !== newRest) throw Error('Patch changed a declaration or executable statement outside the helper allowlist'); for (const name of names) { const target = named(after, name); if (target.length !== 1 || target[0].getText(after) !== named(local, name)[0].getText(local)) throw Error(`Final helper mismatch: ${name}`); } new vm.Script(code, { filename: 'reviewable-gateway-candidate.js' }); return { code, audit: { baselineHash: sha256(baseline), candidateHash: sha256(code), unchangedStatementsHash: sha256(oldRest), helperAllowlist: [...names], functions: audit } }; } export function localCandidates(root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')) { const read = name => fs.readFileSync(path.join(root, name), 'utf8'); const deploy = read('scripts/deploy-admin-functions.mjs'); const template = name => { const match = deploy.match(new RegExp('const ' + name + ' = String.raw`([\\s\\S]*?)\\n`;')); if (!match) throw Error('Cannot find authoritative gateway template ' + name); return match[1]; }; const operations = template('operationsGatewayCode'), integrity = read('scripts/cloud/payroll-integrity.js'); // Exactly the same generator and arguments as deploy-admin-functions.mjs. const calculator = teacherEarningsSupport(operations, integrity); return { mobile: template('appGatewayCode') + '\n' + read('scripts/cloud/mobile-teaching.js') + '\n' + calculator + '\n' + read('scripts/cloud/teacher-earnings.js'), operations: operations + '\n' + integrity, }; } function main(args) { const values = new Map(); let monthly = false; for (let index = 0; index < args.length; index++) { const flag = args[index]; if (flag === '--monthly') { monthly = true; continue; } if (!['--mobile-baseline', '--operations-baseline', '--output-dir'].includes(flag) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Usage: node scripts/speed-round3-patch.mjs [--mobile-baseline private.json] [--operations-baseline private.json] --output-dir private-directory [--monthly]'); if (values.has(flag)) throw Error('Duplicate option ' + flag); values.set(flag, args[++index]); } if (!values.has('--output-dir') || !values.has('--mobile-baseline') && !values.has('--operations-baseline')) throw Error('Provide explicit baseline file(s) and private --output-dir'); if (monthly && !values.has('--mobile-baseline')) throw Error('--monthly requires --mobile-baseline'); const candidates = localCandidates(), out = path.resolve(values.get('--output-dir')), manifest = { monthly, scopes: {} }, prepared = []; for (const scope of ['mobile', 'operations']) { const sourcePath = values.get(`--${scope}-baseline`); if (!sourcePath) continue; const saved = JSON.parse(fs.readFileSync(sourcePath, 'utf8')); if (typeof saved.code !== 'string' || !saved.code.trim()) throw Error(`${scope} baseline must be a saved Function object with nonempty code`); const names = scope === 'mobile' ? [...MOBILE_HELPERS, ...(monthly ? MONTHLY_HELPERS : [])] : OPERATIONS_HELPERS; const result = patchFunctions(saved.code, candidates[scope], names); prepared.push({ filename: `${scope}-candidate.json`, value: { ...saved, code: result.code } }); manifest.scopes[scope] = result.audit; } // Validate every candidate before writing any file. Baseline files are never overwritten. const baselinePaths = ['--mobile-baseline', '--operations-baseline'].map(flag => values.get(flag)).filter(Boolean).map(value => path.resolve(value)); for (const filename of [...prepared.map(item => item.filename), 'patch-audit.json']) if (baselinePaths.includes(path.join(out, filename))) throw Error('Output must not replace a baseline'); fs.mkdirSync(out, { recursive: true, mode: 0o700 }); for (const item of [...prepared, { filename: 'patch-audit.json', value: manifest }]) fs.writeFileSync(path.join(out, item.filename), JSON.stringify(item.value, null, 2) + '\n', { mode: 0o600 }); console.log(JSON.stringify({ outputDir: out, scopes: Object.fromEntries(Object.entries(manifest.scopes).map(([scope, audit]) => [scope, { baselineHash: audit.baselineHash, candidateHash: audit.candidateHash, functions: audit.functions.map(({ name, action }) => ({ name, action })) }])) }, null, 2)); } if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) main(process.argv.slice(2));