128 lines
7.2 KiB
JavaScript
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 };
|
|
}
|