/** Owner-run private portal preparation. Access codes are accepted only by hidden TTY prompts. */ 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 { extractPayload, resourceReferences, rewriteResources } from './portal-resources.mjs'; import { applyRoleMappings, assertProductionMappings, manifestDigest, payloadDigest, summarizeMappings } from './portal-production.mjs'; const defaultRoot = fileURLToPath(new URL('../private/employee-portal/', import.meta.url)); const privateRoot = path.resolve(process.env.AIA_PORTAL_PRIVATE_ROOT || defaultRoot); const timestamp = () => new Date().toISOString().replaceAll(':', '-'); 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 readJson(file) { return JSON.parse(await fs.readFile(file, 'utf8')); } async function atomicPrivateJson(file, value) { const temporary = `${file}.tmp-${process.pid}`; await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); await fs.chmod(temporary, 0o600); await fs.rename(temporary, file); } async function backup(file, label) { try { await fs.copyFile(file, path.join(privateRoot, `${label}-${timestamp()}.json`), fs.constants.COPYFILE_EXCL); } catch (error) { if (error.code !== 'ENOENT') throw error; } } async function sourcePayload(source) { if (!path.isAbsolute(source)) throw new Error('The private legacy source path must be absolute'); return validatePortalPayload(extractPayload(await fs.readFile(source, 'utf8'))); } async function identifyStreams(payload) { const staffCode = await hiddenPrompt('Staff access code: '); const managementCode = await hiddenPrompt('Management access code: '); const identify = async (code) => { const unlocked = []; for (const stream of payload.streams) if (await decryptPortalStream(stream, code) !== null) unlocked.push(stream.id); if (unlocked.length !== 1) throw new Error('Each owner code must authenticate exactly one encrypted stream'); return unlocked[0]; }; const staff = await identify(staffCode), management = await identify(managementCode); if (staff === management) throw new Error('Staff and management codes must identify different streams'); return { staff, management, staffCode, managementCode }; } async function identifyCommand(payload) { const roles = await identifyStreams(payload); const metadata = { schema: 1, payloadSha256: payloadDigest(payload), staff: roles.staff, management: roles.management, verifiedAt: new Date().toISOString() }; roles.staffCode = ''; roles.managementCode = ''; await atomicPrivateJson(path.join(privateRoot, 'stream-role-map.json'), metadata); console.log(JSON.stringify({ identified: true, staffStream: metadata.staff, managementStream: metadata.management }, null, 2)); } async function loadRoleMap(payload) { const roles = await readJson(path.join(privateRoot, 'stream-role-map.json')); if (roles.schema !== 1 || roles.payloadSha256 !== payloadDigest(payload) || !roles.staff || !roles.management || roles.staff === roles.management) throw new Error('The private stream-role map is missing or does not match this ciphertext'); return roles; } async function mapCommand(payload, approve) { const roles = await loadRoleMap(payload); const manifestPath = path.join(privateRoot, 'manifest.json'); const manifest = await readJson(manifestPath); const summary = summarizeMappings(manifest, roles); const report = { schema: 1, generatedAt: new Date().toISOString(), staffOnly: summary.staffOnly, managementOnly: summary.managementOnly, shared: summary.shared, ambiguous: summary.ambiguous, proposed: summary.proposed.map(({ localFile, roles: proposedRoles, filename }) => ({ localFile, roles: proposedRoles, filename })), }; await atomicPrivateJson(path.join(privateRoot, `mapping-summary-${timestamp()}.json`), report); if (approve) { if (summary.ambiguous) throw new Error('Ambiguous resources require specific private resolution before mapping approval'); await backup(manifestPath, 'manifest-before-role-mapping'); await atomicPrivateJson(manifestPath, applyRoleMappings(manifest, roles, { approveShared: true })); } console.log(JSON.stringify({ staffOnly: report.staffOnly, managementOnly: report.managementOnly, shared: report.shared, ambiguous: report.ambiguous, approved: approve, automaticallyMapped: approve ? summary.proposed.length : 0 }, null, 2)); } async function rewriteProduction(payload) { const roles = await loadRoleMap(payload); const manifestPath = path.join(privateRoot, 'manifest.json'); const manifest = await readJson(manifestPath); const mappingCount = assertProductionMappings(manifest); const codes = await identifyStreams(payload); if (codes.staff !== roles.staff || codes.management !== roles.management) throw new Error('Entered codes do not match the previously verified stream-role relationship'); const codeByRole = { staff: codes.staffCode, management: codes.managementCode }; const roleByStream = new Map([[roles.staff, 'staff'], [roles.management, 'management']]); const rewrittenStreams = []; let rewritten = 0; let legacyHubfsReferences = 0; try { for (const stream of payload.streams) { const role = roleByStream.get(stream.id); if (!role) throw new Error('Encrypted payload includes an unidentified stream'); let html = await decryptPortalStream(stream, codeByRole[role]); if (html === null) throw new Error('Owner code no longer authenticates the expected stream'); const result = rewriteResources(html, manifest.resources, role); rewritten += result.count; legacyHubfsReferences += resourceReferences(result.html, stream.id).length; if (legacyHubfsReferences) throw new Error('Legacy employee-resource links remain after rewriting'); const encrypted = await encryptPortalStream(stream.id, result.html, codeByRole[role]); if (await decryptPortalStream(encrypted, codeByRole[role]) !== result.html) throw new Error('Private round-trip verification failed'); const otherRole = role === 'staff' ? 'management' : 'staff'; if (await decryptPortalStream(encrypted, codeByRole[otherRole]) !== null) throw new Error('Encrypted view role separation failed'); rewrittenStreams.push(encrypted); html = ''; } } finally { codes.staffCode = ''; codes.managementCode = ''; codeByRole.staff = ''; codeByRole.management = ''; } const finalPayload = validatePortalPayload({ ...payload, streams: rewrittenStreams }); const output = path.join(privateRoot, 'approved-production-ciphertext.json'); await backup(output, 'approved-production-ciphertext-before-rewrite'); await atomicPrivateJson(output, finalPayload); const readiness = { schema: 1, generatedAt: new Date().toISOString(), payloadSha256: payloadDigest(finalPayload), manifestSha256: manifestDigest(manifest), protectedMappings: mappingCount, legacyHubfsReferences, rewrittenReferences: rewritten, }; await atomicPrivateJson(path.join(privateRoot, 'production-readiness.json'), readiness); console.log(JSON.stringify({ installed: true, payloadSha256: readiness.payloadSha256, protectedMappings: mappingCount, rewrittenReferences: rewritten, legacyHubfsReferences }, null, 2)); } async function main() { const [command, source, ...flags] = process.argv.slice(2); const valid = new Set(['identify', 'propose-mappings', 'approve-mappings', 'rewrite-production']); if (!valid.has(command) || !source || flags.length) throw new Error('Usage: node tools/employee-portal-migrate.mjs identify|propose-mappings|approve-mappings|rewrite-production /absolute/private/footer-source.txt'); await fs.mkdir(privateRoot, { recursive: true, mode: 0o700 }); await fs.chmod(privateRoot, 0o700); const payload = await sourcePayload(source); if (command === 'identify') await identifyCommand(payload); else if (command === 'propose-mappings') await mapCommand(payload, false); else if (command === 'approve-mappings') await mapCommand(payload, true); else await rewriteProduction(payload); } main().catch(() => { console.error('Private portal preparation stopped. Check codes, source, mapping evidence and private-file permissions locally. No credential or decrypted content details are logged.'); process.exitCode = 1; });