/** Owner-run only. No credential arguments, environment passwords or plaintext output. */ import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { decryptPortalStream, encryptPortalStream, validatePortalPayload } from '../src/utils/portal-crypto.mjs'; import { collectResource, extractPayload, resourceReferences, rewriteResources } from './portal-resources.mjs'; const root = fileURLToPath(new URL('../private/employee-portal/', import.meta.url)); async function hiddenPrompt(label) { if (!process.stdin.isTTY || !process.stdin.setRawMode) throw new Error('An interactive private terminal is required'); process.stderr.write(label); process.stdin.setRawMode(true); process.stdin.resume(); return new Promise((resolve, reject) => { let value = ''; const finish = () => { process.stdin.off('data', onData); process.stdin.setRawMode(false); process.stdin.pause(); process.stderr.write('\n'); }; const onData = (data) => { for (const char of data.toString('utf8')) { if (char === '\u0003' || char === '\u0004') { finish(); reject(new Error('Canceled')); return; } if (char === '\r' || char === '\n') { finish(); resolve(value); return; } if (char === '\u007f' || char === '\b') value = value.slice(0, -1); else if (char >= ' ') value += char; } }; process.stdin.on('data', onData); }); } async function main() { const [command, source, ...extra] = process.argv.slice(2); if (!['collect', 'rewrite'].includes(command) || !source || extra.length) throw new Error('Usage: node tools/employee-portal-migrate.mjs collect|rewrite /absolute/private/footer-source.txt'); // No scanning for credentials. The original is read only; never copied into Git. const payload = validatePortalPayload(extractPayload(await fs.readFile(source, 'utf8'))); await fs.mkdir(root, { recursive: true, mode: 0o700 }); await fs.chmod(root, 0o700); const manifestPath = path.join(root, 'manifest.json'); let previous = { resources: [] }; try { previous = JSON.parse(await fs.readFile(manifestPath, 'utf8')); } catch (e) { if (e.code !== 'ENOENT') throw new Error('Private manifest could not be read'); } const resources = new Map(previous.resources.map((r) => [r.sourceUrl, { ...r, references: [] }])); const nextStreams = [], unlocked = []; let rewritten = 0; for (let index = 0; index < payload.streams.length; index++) { let pass = await hiddenPrompt(`Access code for portal view ${index + 1} (not echoed): `); const stream = payload.streams[index]; let html = await decryptPortalStream(stream, pass); if (html === null) throw new Error('Code did not authenticate the requested view; no content was written'); // Reject identical credentials across views to preserve stream separation. for (let other = 0; other < payload.streams.length; other++) if (other !== index && await decryptPortalStream(payload.streams[other], pass) !== null) throw new Error('View codes must be distinct'); for (const ref of resourceReferences(html, stream.id)) { const record = resources.get(ref.sourceUrl) ?? { sourceUrl: ref.sourceUrl, originalPath: ref.originalPath, classification: 'UNRESOLVED', references: [], downloadStatus: 'pending', deliveryPath: null, rewriteApproved: false, destinationImplemented: false, destinationVerified: false, destinationAccessControlled: false }; record.references.push(ref); resources.set(ref.sourceUrl, record); } if (command === 'rewrite') { const result = rewriteResources(html, previous.resources.filter((r) => r.references.some((ref) => ref.view === stream.id))); html = result.html; rewritten += result.count; const next = await encryptPortalStream(stream.id, html, pass); if (await decryptPortalStream(next, pass) !== html) throw new Error('Re-encryption verification failed'); for (const other of nextStreams) if (await decryptPortalStream(other, pass) !== null) throw new Error('View separation verification failed'); nextStreams.push(next); } unlocked.push(stream.id); html = ''; pass = ''; } const stamp = new Date().toISOString().replaceAll(':', '-'); if (command === 'collect') { const approvedOrigins = Array.isArray(previous.approvedOrigins) ? previous.approvedOrigins.filter((origin) => { try { return new URL(origin).origin === origin && new URL(origin).protocol === 'https:'; } catch { return false; } }) : undefined; for (const [url, record] of resources) { try { resources.set(url, await collectResource(record, path.join(root, 'hubfs'), approvedOrigins)); } catch { resources.set(url, { ...record, downloadStatus: 'failed-or-unapproved-redirect', verificationStatus: 'owner-review-required' }); } } const manifest = { version: 1, updated: stamp, approvedOrigins: approvedOrigins ?? ['https://www.azinstitute4autism.com'], views: unlocked, resources: [...resources.values()], }; // Retain each previous manifest; no original evidence is overwritten. try { await fs.copyFile(manifestPath, path.join(root, `manifest-before-${stamp}.json`), fs.constants.COPYFILE_EXCL); } catch (e) { if (e.code !== 'ENOENT') throw e; } await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), { mode: 0o600 }); await fs.writeFile(path.join(root, `ciphertext-${stamp}.json`), JSON.stringify(payload), { mode: 0o600, flag: 'wx' }); } else { await fs.writeFile(path.join(root, `rewritten-ciphertext-${stamp}.json`), JSON.stringify(validatePortalPayload({ ...payload, streams: nextStreams })), { mode: 0o600, flag: 'wx' }); } const list = [...resources.values()]; console.log(JSON.stringify({ command, distinctResources: list.length, totalReferences: list.reduce((n, r) => n + r.references.length, 0), collected: list.filter((r) => ['downloaded','verified-existing'].includes(r.downloadStatus)).length, unresolved: list.filter((r) => r.classification === 'UNRESOLVED').length, rewritten }, null, 2)); } main().catch(() => { console.error('Private migration stopped. Check access codes, source format, private manifest and file permissions locally. No credential or content details are logged.'); process.exitCode = 1; });