fix(portal): prepare approved resource mappings safely

This commit is contained in:
DeCentN2Madness authored and AIAMarketing committed 2026-11-05 00:40:18 -07:00
1 parent 36d417a422
commit 81492125f4
11 files changed
+427 -153

No files matched your search

+126 -66
View File
@@ -1,11 +1,15 @@
/** Owner-run only. No credential arguments, environment passwords or plaintext output. */
/** 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 { collectResource, extractPayload, resourceReferences, rewriteResources } from './portal-resources.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(':', '-');
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);
@@ -24,67 +28,123 @@ async function hiddenPrompt(label) {
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));
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);
}
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; });
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; });