Files
aia-website/www/tools/portal-production.mjs

128 lines
7.2 KiB
JavaScript

/** Private employee-portal mapping and production-readiness helpers.
* These helpers deliberately never decrypt portal streams or read access codes.
*/
import { createHash } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { validatePortalPayload } from '../src/utils/portal-crypto.mjs';
const internal = new Set(['STAFF-INTERNAL', 'MANAGEMENT-INTERNAL']);
const safeName = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,240}$/.test(value) && value !== '.' && value !== '..';
const hash = (value) => createHash('sha256').update(value).digest('hex');
export const payloadDigest = (payload) => hash(Buffer.from(JSON.stringify(validatePortalPayload(payload))));
export const manifestDigest = (manifest) => hash(Buffer.from(JSON.stringify(manifest)));
function roleForClassification(classification) {
if (classification === 'STAFF-INTERNAL') return 'staff';
if (classification === 'MANAGEMENT-INTERNAL') return 'management';
return null;
}
function safeDeliveryPath(role, value) {
const prefix = `/${role}-resources/`;
if (typeof value !== 'string' || !value.startsWith(prefix)) return false;
const name = value.slice(prefix.length);
return safeName(name) && value === `${prefix}${name}`;
}
export function mappingsFor(record) {
if (Array.isArray(record.installMappings)) return record.installMappings;
const role = roleForClassification(record.classification);
return role ? [{
role,
classification: record.classification,
deliveryPath: record.deliveryPath,
mappingApproved: record.mappingApproved,
accessPolicyApproved: record.accessPolicyApproved,
rewriteApproved: record.rewriteApproved,
destinationInstalled: record.destinationInstalled,
destinationHashVerified: record.destinationHashVerified,
destinationPermissionsVerified: record.destinationPermissionsVerified,
destinationAccessVerified: record.destinationAccessVerified,
}] : [];
}
export function approvedMapping(record, role) {
const mapping = mappingsFor(record).find((candidate) => candidate.role === role);
if (!mapping || !internal.has(mapping.classification) || !safeDeliveryPath(role, mapping.deliveryPath)) return null;
if (!mapping.mappingApproved || !mapping.accessPolicyApproved || !mapping.rewriteApproved) return null;
return mapping;
}
export function deriveDestinationName(record) {
let originalPath = record.originalPath;
try { originalPath ||= new URL(record.sourceUrl).pathname; } catch { return null; }
const extension = path.extname(originalPath).toLowerCase();
if (!/^[.][a-z0-9]{1,10}$/.test(extension) || !safeName(record.localFile)) return null;
return `${record.localFile}${extension}`;
}
export function summarizeMappings(manifest, streamRoles) {
if (!streamRoles || !streamRoles.staff || !streamRoles.management || streamRoles.staff === streamRoles.management) throw new Error('A distinct staff/management stream-role mapping is required');
const summary = { staffOnly: 0, managementOnly: 0, shared: 0, ambiguous: 0, proposed: [] };
for (const record of manifest.resources || []) {
const views = new Set((record.references || []).map((reference) => reference.view));
const roles = new Set([...views].map((view) => view === streamRoles.staff ? 'staff' : view === streamRoles.management ? 'management' : null));
if (roles.has(null) || !roles.size || !deriveDestinationName(record)) { summary.ambiguous++; continue; }
if (roles.size === 2) summary.shared++; else if (roles.has('staff')) summary.staffOnly++; else summary.managementOnly++;
summary.proposed.push({ sourceUrl: record.sourceUrl, localFile: record.localFile, roles: [...roles].sort(), filename: deriveDestinationName(record) });
}
return summary;
}
export function applyRoleMappings(manifest, streamRoles, { approveShared = false } = {}) {
const summary = summarizeMappings(manifest, streamRoles);
const resources = (manifest.resources || []).map((record) => {
const proposal = summary.proposed.find((entry) => entry.sourceUrl === record.sourceUrl);
if (!proposal || (proposal.roles.length > 1 && !approveShared)) return { ...record };
const installMappings = proposal.roles.map((role) => ({
role,
classification: role === 'staff' ? 'STAFF-INTERNAL' : 'MANAGEMENT-INTERNAL',
deliveryPath: `/${role}-resources/${proposal.filename}`,
mappingApproved: true,
accessPolicyApproved: true,
rewriteApproved: true,
// Server-installation evidence is intentionally separate and remains false.
destinationInstalled: false,
destinationHashVerified: false,
destinationPermissionsVerified: false,
destinationAccessVerified: false,
}));
return {
...record,
references: (record.references || []).map((reference) => ({
...reference,
role: reference.view === streamRoles.staff ? 'staff' : reference.view === streamRoles.management ? 'management' : undefined,
})),
classification: installMappings.length === 1 ? installMappings[0].classification : 'UNRESOLVED',
installMappings,
mappingStatus: installMappings.length === 1 ? 'approved-for-installation' : 'approved-shared-role-installation',
};
});
return { ...manifest, resources, mappingSchema: 2, mappingUpdatedAt: new Date().toISOString() };
}
export function assertProductionMappings(manifest) {
let count = 0;
for (const record of manifest.resources || []) {
const references = record.references || [];
if (!references.length) continue;
const roles = new Set(references.map((ref) => ref.role).filter(Boolean));
// Role labels are only required after the private stream-role step.
if (!roles.size) throw new Error('Private manifest lacks verified stream-to-role references');
for (const role of roles) {
if (!approvedMapping(record, role)) throw new Error('Private manifest has an unresolved or unapproved protected resource mapping');
count++;
}
}
if (!count) throw new Error('Private manifest has no approved protected resource mappings');
return count;
}
export async function readPrivateJson(file, label) {
const stat = await fs.lstat(file).catch(() => null);
if (!stat?.isFile() || stat.isSymbolicLink() || stat.size > 12_000_000) throw new Error(`${label} is unavailable or unsafe`);
return JSON.parse(await fs.readFile(file, 'utf8'));
}
export async function assertProductionReadiness(privateRoot, payloadFile) {
const payload = validatePortalPayload(await readPrivateJson(payloadFile, 'Portal ciphertext'));
const manifest = await readPrivateJson(path.join(privateRoot, 'manifest.json'), 'Private portal manifest');
const readiness = await readPrivateJson(path.join(privateRoot, 'production-readiness.json'), 'Private production readiness record');
if (readiness.schema !== 1 || readiness.payloadSha256 !== payloadDigest(payload) || readiness.manifestSha256 !== manifestDigest(manifest)) throw new Error('Production portal readiness record does not match the current private ciphertext and manifest');
const mappings = assertProductionMappings(manifest);
if (readiness.protectedMappings !== mappings || readiness.legacyHubfsReferences !== 0) throw new Error('Production portal readiness record does not prove complete protected-link rewriting');
return { payload, manifest, readiness };
}