speed-round3-patch.mjs 7.2 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import vm from 'node:vm';
  4. import { createHash } from 'node:crypto';
  5. import { fileURLToPath, pathToFileURL } from 'node:url';
  6. import ts from 'typescript';
  7. import { teacherEarningsSupport } from './teacher-earnings-transform.mjs';
  8. // Builds reviewable private artifacts only. No credentials, network, SQL or deployment.
  9. export const MOBILE_HELPERS = Object.freeze(['appCompanionOverview', 'appAccountOverview', 'appLiveScopeVersion', 'appReviewPage']);
  10. export const MONTHLY_HELPERS = Object.freeze(['teacherEarningsCalculator', 'teacherEarningsSourceRows', 'teacherEarningsInputs', 'teacherPrepareEarnings', 'teacherCalculatePreparedEarnings', 'teacherLessonEarnings', 'teacherEarningsFingerprint', 'teacherMonthlyEarningsInputs', 'teacherMonthlyLessonEarnings', 'teacherIncomeSummary']);
  11. export const OPERATIONS_HELPERS = Object.freeze(['payrollSyncStatusFromCutoff', 'payrollSettlementSyncStatus', 'payrollFreshReadSnapshot', 'payrollReadUsers', 'payrollCanonicalSnapshot', 'payrollPreviewUncached']);
  12. const sha256 = value => createHash('sha256').update(value).digest('hex');
  13. const parse = code => ts.createSourceFile('gateway.js', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
  14. const named = (source, name) => source.statements.filter(node => ts.isFunctionDeclaration(node) && node.name?.text === name);
  15. export function patchFunctions(baseline, candidate, names) {
  16. if (new Set(names).size !== names.length) throw Error('Duplicate helper in allowlist');
  17. const before = parse(baseline), local = parse(candidate), replacements = [], additions = [], audit = [];
  18. for (const name of names) {
  19. const existing = named(before, name), desired = named(local, name);
  20. if (existing.length > 1 || desired.length !== 1) throw Error(`Expected unique helper ${name}: baseline=${existing.length}, candidate=${desired.length}`);
  21. const replacement = desired[0].getText(local), old = existing[0]?.getText(before);
  22. if (old === replacement) { audit.push({ name, action: 'unchanged', hash: sha256(replacement) }); continue; }
  23. if (existing.length) replacements.push({ start: existing[0].getStart(before), end: existing[0].end, replacement });
  24. else additions.push(replacement);
  25. audit.push({ name, action: existing.length ? 'replace' : 'add', beforeHash: old ? sha256(old) : null, afterHash: sha256(replacement) });
  26. }
  27. let code = baseline;
  28. for (const edit of replacements.sort((a, b) => b.start - a.start)) code = code.slice(0, edit.start) + edit.replacement + code.slice(edit.end);
  29. if (additions.length) code += '\n\n' + additions.join('\n\n') + '\n';
  30. const after = parse(code), allowed = new Set(names);
  31. const unselected = source => source.statements.filter(node => !(ts.isFunctionDeclaration(node) && allowed.has(node.name?.text))).map(node => node.getText(source));
  32. const oldRest = JSON.stringify(unselected(before)), newRest = JSON.stringify(unselected(after));
  33. if (oldRest !== newRest) throw Error('Patch changed a declaration or executable statement outside the helper allowlist');
  34. for (const name of names) {
  35. const target = named(after, name);
  36. if (target.length !== 1 || target[0].getText(after) !== named(local, name)[0].getText(local)) throw Error(`Final helper mismatch: ${name}`);
  37. }
  38. new vm.Script(code, { filename: 'reviewable-gateway-candidate.js' });
  39. return { code, audit: { baselineHash: sha256(baseline), candidateHash: sha256(code), unchangedStatementsHash: sha256(oldRest), helperAllowlist: [...names], functions: audit } };
  40. }
  41. export function localCandidates(root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')) {
  42. const read = name => fs.readFileSync(path.join(root, name), 'utf8');
  43. const deploy = read('scripts/deploy-admin-functions.mjs');
  44. const template = name => {
  45. const match = deploy.match(new RegExp('const ' + name + ' = String.raw`([\\s\\S]*?)\\n`;'));
  46. if (!match) throw Error('Cannot find authoritative gateway template ' + name);
  47. return match[1];
  48. };
  49. const operations = template('operationsGatewayCode'), integrity = read('scripts/cloud/payroll-integrity.js');
  50. // Exactly the same generator and arguments as deploy-admin-functions.mjs.
  51. const calculator = teacherEarningsSupport(operations, integrity);
  52. return {
  53. mobile: template('appGatewayCode') + '\n' + read('scripts/cloud/mobile-teaching.js') + '\n' + calculator + '\n' + read('scripts/cloud/teacher-earnings.js'),
  54. operations: operations + '\n' + integrity,
  55. };
  56. }
  57. function main(args) {
  58. const values = new Map(); let monthly = false;
  59. for (let index = 0; index < args.length; index++) {
  60. const flag = args[index];
  61. if (flag === '--monthly') { monthly = true; continue; }
  62. 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]');
  63. if (values.has(flag)) throw Error('Duplicate option ' + flag);
  64. values.set(flag, args[++index]);
  65. }
  66. if (!values.has('--output-dir') || !values.has('--mobile-baseline') && !values.has('--operations-baseline')) throw Error('Provide explicit baseline file(s) and private --output-dir');
  67. if (monthly && !values.has('--mobile-baseline')) throw Error('--monthly requires --mobile-baseline');
  68. const candidates = localCandidates(), out = path.resolve(values.get('--output-dir')), manifest = { monthly, scopes: {} }, prepared = [];
  69. for (const scope of ['mobile', 'operations']) {
  70. const sourcePath = values.get(`--${scope}-baseline`); if (!sourcePath) continue;
  71. const saved = JSON.parse(fs.readFileSync(sourcePath, 'utf8'));
  72. if (typeof saved.code !== 'string' || !saved.code.trim()) throw Error(`${scope} baseline must be a saved Function object with nonempty code`);
  73. const names = scope === 'mobile' ? [...MOBILE_HELPERS, ...(monthly ? MONTHLY_HELPERS : [])] : OPERATIONS_HELPERS;
  74. const result = patchFunctions(saved.code, candidates[scope], names);
  75. prepared.push({ filename: `${scope}-candidate.json`, value: { ...saved, code: result.code } });
  76. manifest.scopes[scope] = result.audit;
  77. }
  78. // Validate every candidate before writing any file. Baseline files are never overwritten.
  79. const baselinePaths = ['--mobile-baseline', '--operations-baseline'].map(flag => values.get(flag)).filter(Boolean).map(value => path.resolve(value));
  80. 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');
  81. fs.mkdirSync(out, { recursive: true, mode: 0o700 });
  82. 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 });
  83. 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));
  84. }
  85. if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) main(process.argv.slice(2));