fix(portal): prepare approved resource mappings safely
This commit is contained in:
1 parent
36d417a422
commit
81492125f4
11 files changed
+427
-153
No files matched your search
@@ -6,6 +6,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { rsyncArguments, sshTarget } from './deploy.mjs';
|
||||
import { mappingsFor, approvedMapping } from './portal-production.mjs';
|
||||
|
||||
const toolRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const repositoryRoot = path.resolve(toolRoot, '..');
|
||||
@@ -19,14 +20,18 @@ function run(command, args, { quiet = false } = {}) {
|
||||
if (!quiet && result.stdout) process.stdout.write(result.stdout);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
function runBytes(command, args) {
|
||||
const result = spawnSync(command, args, { cwd: toolRoot });
|
||||
if (result.error || result.status !== 0) fail(`${command} failed${result.stderr?.length ? `: ${result.stderr.toString().trim()}` : ''}`);
|
||||
return result.stdout;
|
||||
}
|
||||
function safeName(value) { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,240}$/.test(value) && value !== '.' && value !== '..'; }
|
||||
function roleAndName(record) {
|
||||
if (!['STAFF-INTERNAL', 'MANAGEMENT-INTERNAL'].includes(record.classification)) fail('Every installed resource must have an approved internal classification');
|
||||
const role = record.classification === 'STAFF-INTERNAL' ? 'staff' : 'management';
|
||||
function roleAndName(record, mapping) {
|
||||
const role = mapping?.role;
|
||||
if (!['staff', 'management'].includes(role) || !approvedMapping(record, role)) fail('Resource mapping is not approved for installation');
|
||||
const prefix = `/${role}-resources/`;
|
||||
if (!record.destinationImplemented || !record.destinationVerified || !record.destinationAccessControlled || !record.rewriteApproved || typeof record.deliveryPath !== 'string' || !record.deliveryPath.startsWith(prefix)) fail('Resource destination is not deployment-approved');
|
||||
const name = record.deliveryPath.slice(prefix.length);
|
||||
if (!safeName(name) || record.deliveryPath !== `${prefix}${name}`) fail('Resource destination path is unsafe');
|
||||
const name = mapping.deliveryPath.slice(prefix.length);
|
||||
if (!safeName(name) || mapping.deliveryPath !== `${prefix}${name}`) fail('Resource destination path is unsafe');
|
||||
return { role, name };
|
||||
}
|
||||
export async function prepareResourcePlan(privateRoot, staging, sourceCommit) {
|
||||
@@ -38,20 +43,22 @@ export async function prepareResourcePlan(privateRoot, staging, sourceCommit) {
|
||||
if (!Array.isArray(manifest.resources)) fail('Private resource manifest has no resource list');
|
||||
const seen = new Set(), files = [];
|
||||
for (const record of manifest.resources) {
|
||||
if (!['STAFF-INTERNAL','MANAGEMENT-INTERNAL'].includes(record.classification)) continue;
|
||||
const { role, name } = roleAndName(record);
|
||||
if (!safeName(record.localFile) || !/^[0-9a-f]{64}$/.test(record.sha256 ?? '') || !Number.isInteger(record.size) || record.size <= 0 || record.size > 50 * 1024 * 1024) fail('Approved resource lacks bounded verified local metadata');
|
||||
if (seen.has(`${role}/${name}`)) fail('Two resources map to the same protected destination');
|
||||
seen.add(`${role}/${name}`);
|
||||
const source = path.join(privateRoot, 'hubfs', record.localFile);
|
||||
const stat = await fs.lstat(source).catch(() => null);
|
||||
if (!stat?.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) fail(`Approved resource is missing or unsafe: ${role}/${name}`);
|
||||
const bytes = await fs.readFile(source);
|
||||
if (bytes.length !== record.size || hash(bytes) !== record.sha256) fail(`Approved resource hash mismatch: ${role}/${name}`);
|
||||
const destination = path.join(staging, role);
|
||||
await fs.mkdir(destination, { recursive: true, mode: 0o700 });
|
||||
await fs.copyFile(source, path.join(destination, record.localFile), fs.constants.COPYFILE_EXCL);
|
||||
files.push({ role, name, stageName: record.localFile, sha256: record.sha256, size: record.size });
|
||||
for (const mapping of mappingsFor(record)) {
|
||||
if (!['staff', 'management'].includes(mapping.role)) continue;
|
||||
const { role, name } = roleAndName(record, mapping);
|
||||
if (!safeName(record.localFile) || !/^[0-9a-f]{64}$/.test(record.sha256 ?? '') || !Number.isInteger(record.size) || record.size <= 0 || record.size > 50 * 1024 * 1024) fail('Approved resource lacks bounded verified local metadata');
|
||||
if (seen.has(`${role}/${name}`)) fail('Two resources map to the same protected destination');
|
||||
seen.add(`${role}/${name}`);
|
||||
const source = path.join(privateRoot, 'hubfs', record.localFile);
|
||||
const stat = await fs.lstat(source).catch(() => null);
|
||||
if (!stat?.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) fail(`Approved resource is missing or unsafe: ${role}/${name}`);
|
||||
const bytes = await fs.readFile(source);
|
||||
if (bytes.length !== record.size || hash(bytes) !== record.sha256) fail(`Approved resource hash mismatch: ${role}/${name}`);
|
||||
const destination = path.join(staging, role);
|
||||
await fs.mkdir(destination, { recursive: true, mode: 0o700 });
|
||||
await fs.copyFile(source, path.join(destination, record.localFile), fs.constants.COPYFILE_EXCL);
|
||||
files.push({ role, name, stageName: record.localFile, sha256: record.sha256, size: record.size });
|
||||
}
|
||||
}
|
||||
if (!files.length) fail('No deployment-approved internal resources are present; reconcile the private manifest first');
|
||||
return { schema: 1, sourceCommit, files: files.sort((a, b) => `${a.role}/${a.name}`.localeCompare(`${b.role}/${b.name}`)) };
|
||||
@@ -86,6 +93,13 @@ async function main() {
|
||||
const host = process.env.AIA_DEPLOY_HOST, identity = process.env.AIA_DEPLOY_SSH_IDENTITY;
|
||||
const [ssh, ...sshOptions] = sshTarget(host, identity);
|
||||
const remoteStage = `${root}/staging/portal-resources-${sha}`, remotePlan = `/root/aia-portal-resources-${sha}.tsv`;
|
||||
const reviewedInstaller = runBytes('git', ['-C', repositoryRoot, 'show', `${sha}:www/tools/portal-resource-install.sh`]);
|
||||
const localInstaller = await fs.readFile(path.join(toolRoot, 'tools', 'portal-resource-install.sh'));
|
||||
if (!reviewedInstaller.equals(localInstaller)) fail('Local resource installer does not match the reviewed source commit');
|
||||
const installerHash = hash(reviewedInstaller);
|
||||
const installerFile = path.join(temporary, 'portal-resource-install.sh');
|
||||
await fs.writeFile(installerFile, reviewedInstaller, { mode: 0o600 });
|
||||
const remoteInstaller = `${remoteStage}/portal-resource-install.sh`;
|
||||
const transport = `${ssh} ${sshOptions.map((item) => `'${item.replaceAll("'", "'\\''")}'`).join(' ')}`;
|
||||
run(ssh, [...sshOptions, host, `install -d -m 0700 ${remoteStage}/staff ${remoteStage}/management`]);
|
||||
for (const role of ['staff','management']) {
|
||||
@@ -94,7 +108,9 @@ async function main() {
|
||||
run('rsync', args);
|
||||
}
|
||||
run('rsync', ['-rlt', '--chown=root:root', '--chmod=Fu=rw,Fgo=', '-e', transport, planFile, `${host}:${remotePlan}`]);
|
||||
run(ssh, [...sshOptions, host, `env -i PATH=/run/current-system/sw/bin:/usr/bin:/bin HOME=/root /bin/sh ${root}/www/tools/portal-resource-install.sh ${remoteStage} ${remotePlan}`]);
|
||||
run('rsync', ['-rlt', '--chown=root:root', '--chmod=Fu=rw,Fgo=', '-e', transport, installerFile, `${host}:${remoteInstaller}`]);
|
||||
run(ssh, [...sshOptions, host, `test "$(sha256sum ${remoteInstaller} | awk '{print $1}')" = ${installerHash}`]);
|
||||
run(ssh, [...sshOptions, host, `env -i PATH=/run/current-system/sw/bin:/usr/bin:/bin HOME=/root /bin/sh ${remoteInstaller} ${remoteStage} ${remotePlan}`]);
|
||||
console.log(JSON.stringify({ installed: true, sourceCommit: sha, resources: plan.files.length }, null, 2));
|
||||
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ async function writeApprovedResource(privateRoot, hubfs, bytes = Buffer.from('sy
|
||||
const localFile = 'a'.repeat(64);
|
||||
await fs.writeFile(path.join(hubfs, localFile), bytes, { mode: 0o600 });
|
||||
await fs.writeFile(path.join(privateRoot, 'manifest.json'), JSON.stringify({ resources: [{
|
||||
classification: 'STAFF-INTERNAL', deliveryPath: '/staff-resources/guide.pdf', destinationImplemented: true, destinationVerified: true, destinationAccessControlled: true, rewriteApproved: true, localFile, sha256: digest(bytes), size: bytes.length,
|
||||
classification: 'STAFF-INTERNAL', deliveryPath: '/staff-resources/guide.pdf', mappingApproved: true, accessPolicyApproved: true, rewriteApproved: true, destinationInstalled: false, destinationHashVerified: false, destinationPermissionsVerified: false, destinationAccessVerified: false, localFile, sha256: digest(bytes), size: bytes.length,
|
||||
}] }));
|
||||
return { localFile, bytes };
|
||||
}
|
||||
@@ -40,8 +40,22 @@ test('resource plan copies only hash-verified approved protected resources', asy
|
||||
assert.equal(plan.files.length, 1);
|
||||
assert.equal((await fs.readFile(path.join(stage, 'staff', localFile))).toString(), bytes.toString());
|
||||
assert.match(serializeResourcePlan(plan), /^AIA_PORTAL_RESOURCE_PLAN_V1\nsourceCommit=a{40}\nfile\tstaff\tguide\.pdf\t/);
|
||||
await fs.writeFile(path.join(privateRoot, 'manifest.json'), JSON.stringify({ resources: [{ classification: 'STAFF-INTERNAL', deliveryPath: '/staff-resources/../bad', localFile, sha256: digest(bytes), size: bytes.length }] }));
|
||||
await assert.rejects(prepareResourcePlan(privateRoot, path.join(root, 'second'), sha), /destination/);
|
||||
await fs.writeFile(path.join(privateRoot, 'manifest.json'), JSON.stringify({ resources: [{ classification: 'STAFF-INTERNAL', deliveryPath: '/staff-resources/../bad', mappingApproved: true, accessPolicyApproved: true, rewriteApproved: true, localFile, sha256: digest(bytes), size: bytes.length }] }));
|
||||
await assert.rejects(prepareResourcePlan(privateRoot, path.join(root, 'second'), sha), /approved for installation/);
|
||||
} finally { await fs.rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('one verified source may be installed into both protected role namespaces', async () => {
|
||||
const { root, privateRoot, hubfs, stage } = await fixture();
|
||||
try {
|
||||
const localFile = 'b'.repeat(64), bytes = Buffer.from('synthetic shared non-phi document');
|
||||
await fs.writeFile(path.join(hubfs, localFile), bytes, { mode: 0o600 });
|
||||
const mapping = (role) => ({ role, classification: role === 'staff' ? 'STAFF-INTERNAL' : 'MANAGEMENT-INTERNAL', deliveryPath: `/${role}-resources/shared.pdf`, mappingApproved: true, accessPolicyApproved: true, rewriteApproved: true, destinationInstalled: false, destinationHashVerified: false, destinationPermissionsVerified: false, destinationAccessVerified: false });
|
||||
await fs.writeFile(path.join(privateRoot, 'manifest.json'), JSON.stringify({ resources: [{ classification: 'UNRESOLVED', installMappings: [mapping('staff'), mapping('management')], localFile, sha256: digest(bytes), size: bytes.length }] }));
|
||||
const plan = await prepareResourcePlan(privateRoot, stage, sha);
|
||||
assert.deepEqual(plan.files.map(({ role }) => role), ['management', 'staff']);
|
||||
assert.equal((await fs.readFile(path.join(stage, 'staff', localFile))).toString(), bytes.toString());
|
||||
assert.equal((await fs.readFile(path.join(stage, 'management', localFile))).toString(), bytes.toString());
|
||||
} finally { await fs.rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { encryptPortalStream, portalAlgorithm, validatePortalPayload } from '../src/utils/portal-crypto.mjs';
|
||||
import { encryptPortalStream, portalAlgorithm } from '../src/utils/portal-crypto.mjs';
|
||||
import { verifyArtifact } from './phase10-artifact-verify.mjs';
|
||||
import { assertProductionReadiness } from './portal-production.mjs';
|
||||
|
||||
const toolRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const repositoryRoot = path.resolve(toolRoot, '..');
|
||||
@@ -99,11 +100,6 @@ function assertPreflight({ requirePrimary = true } = {}) {
|
||||
for (const command of ['git', 'tar', 'ssh', 'rsync']) if (!commandExists(command)) fail(`Required deployment tool is unavailable: ${command}`);
|
||||
return sha;
|
||||
}
|
||||
async function payloadFrom(file) {
|
||||
const stat = await fs.lstat(file);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 12_000_000) fail('Portal ciphertext must be a bounded regular private file');
|
||||
return validatePortalPayload(JSON.parse(await fs.readFile(file, 'utf8')));
|
||||
}
|
||||
async function syntheticPayload(directory) {
|
||||
const payload = { alg: portalAlgorithm, streams: [
|
||||
await encryptPortalStream('synthetic-staff', '<p>Synthetic portal fixture.</p>', 'synthetic-staff-code'),
|
||||
@@ -181,7 +177,12 @@ async function main() {
|
||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-deploy-'));
|
||||
try {
|
||||
const configured = process.env.AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE;
|
||||
const portal = configured ? { file: configured, payload: await payloadFrom(configured), synthetic: false } : await syntheticPayload(temporary);
|
||||
let portal;
|
||||
if (configured) {
|
||||
const privateRoot = path.resolve(process.env.AIA_PORTAL_PRIVATE_ROOT || path.dirname(configured));
|
||||
const readiness = await assertProductionReadiness(privateRoot, configured);
|
||||
portal = { file: configured, payload: readiness.payload, synthetic: false };
|
||||
} else portal = await syntheticPayload(temporary);
|
||||
if (!options.dryRun && portal.synthetic) fail('A real deployment requires AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE');
|
||||
if (!options.dryRun && !options.confirm) fail('Refusing to upload without --confirm; use --dry-run for local verification');
|
||||
const env = { ...process.env, PUBLIC_ALLOW_INDEXING: 'true', PUBLIC_AIA_FORMS_MODE: 'live', PUBLIC_COUNTERS_MODE: 'off', AIA_PORTAL_PRODUCTION_APPROVED: 'true', AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE: portal.file, AIA_PORTAL_LOCAL_PREVIEW_APPROVED: 'false' };
|
||||
|
||||
@@ -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; });
|
||||
@@ -0,0 +1,127 @@
|
||||
/** 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 };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { approvedMapping } from './portal-production.mjs';
|
||||
export const sourceOrigin = 'https://www.azinstitute4autism.com';
|
||||
export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
@@ -28,16 +29,17 @@ export function resourceReferences(html, view) {
|
||||
for (const match of html.matchAll(/url\(\s*['"]?([^)'"\s]+)['"]?\s*\)/g)) record(match[1], '', 'css-url');
|
||||
return references;
|
||||
}
|
||||
export function rewriteResources(html, records) {
|
||||
export function rewriteResources(html, records, role) {
|
||||
const mappings = new Map();
|
||||
for (const record of records) {
|
||||
if (!record.rewriteApproved) continue;
|
||||
if (!record.destinationImplemented || !record.destinationVerified || !record.deliveryPath?.startsWith('/') || record.deliveryPath.startsWith('//') || /[?<>"'\s]/.test(record.deliveryPath)) throw new Error('Unverified resource mapping');
|
||||
if (!['PUBLIC-APPROVED', 'STAFF-INTERNAL', 'MANAGEMENT-INTERNAL'].includes(record.classification)) throw new Error('Unclassified resource mapping');
|
||||
if (record.classification !== 'PUBLIC-APPROVED' && !record.destinationAccessControlled) throw new Error('Internal destination lacks access control');
|
||||
const mapping = role ? approvedMapping(record, role) : null;
|
||||
// Public links use their legacy, explicitly approved single-path contract.
|
||||
const publicMapping = record.classification === 'PUBLIC-APPROVED' && record.rewriteApproved && record.mappingApproved && typeof record.deliveryPath === 'string' && record.deliveryPath.startsWith('/') && !record.deliveryPath.startsWith('//') && !/[?<>"'\s]/.test(record.deliveryPath);
|
||||
if (!mapping && !publicMapping) continue;
|
||||
const deliveryPath = mapping?.deliveryPath ?? record.deliveryPath;
|
||||
for (const ref of record.references) {
|
||||
if (ref.attribute === 'css-url' || ref.attribute === 'srcset') throw new Error('CSS/srcset rewriting requires separate review');
|
||||
mappings.set(ref.original, record.deliveryPath);
|
||||
mappings.set(ref.original, deliveryPath);
|
||||
}
|
||||
}
|
||||
let count = 0;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { decryptPortalStream, encryptPortalStream, validatePortalPayload, portal
|
||||
import { collectResource, resourceReferences, rewriteResources } from './portal-resources.mjs';
|
||||
import { isFormACompletion } from '../src/utils/jotform-completion.ts';
|
||||
import { portalLinkPresentation } from '../src/utils/portal-presentation.ts';
|
||||
import { applyRoleMappings, approvedMapping, summarizeMappings } from './portal-production.mjs';
|
||||
|
||||
test('completion envelope requires an exact approved origin, active frame window and string Form A ID', () => {
|
||||
const source = {};
|
||||
@@ -108,12 +109,28 @@ test('resource extraction reconciles duplicate views and rewrites only approved
|
||||
const html = '<h2>Synthetic</h2><a href="/hubfs/example.pdf">Example</a><a href="/hubfs/example.pdf">Again</a><a href="https://unapproved.invalid/form">Other</a>';
|
||||
const refs = resourceReferences(html, 'synthetic');
|
||||
assert.equal(refs.length, 2); assert.equal(new Set(refs.map((r) => r.sourceUrl)).size, 1);
|
||||
const record = { references: refs, rewriteApproved: true, destinationImplemented: true, destinationVerified: true, deliveryPath: '/staff-resources/example.pdf', classification: 'STAFF-INTERNAL' };
|
||||
assert.throws(() => rewriteResources(html, [record]));
|
||||
const result = rewriteResources(html, [{ ...record, destinationAccessControlled: true }]);
|
||||
const record = { references: refs, rewriteApproved: true, mappingApproved: true, deliveryPath: '/staff-resources/example.pdf', classification: 'STAFF-INTERNAL' };
|
||||
assert.equal(rewriteResources(html, [record], 'staff').html, html);
|
||||
const result = rewriteResources(html, [{ ...record, accessPolicyApproved: true }], 'staff');
|
||||
assert.equal(result.count, 2); assert.ok(result.html.includes('>Again</a>')); assert.ok(result.html.includes('https://unapproved.invalid/form'));
|
||||
assert.equal(rewriteResources(html, []).html, html);
|
||||
});
|
||||
test('role-based resource mapping separates source approval from later server verification', () => {
|
||||
const record = {
|
||||
sourceUrl: 'https://www.azinstitute4autism.com/hubfs/guide.pdf', originalPath: '/hubfs/guide.pdf',
|
||||
localFile: 'a'.repeat(64), references: [{ view: 'staff-stream' }, { view: 'management-stream' }],
|
||||
};
|
||||
const roles = { staff: 'staff-stream', management: 'management-stream' };
|
||||
const summary = summarizeMappings({ resources: [record] }, roles);
|
||||
assert.equal(summary.shared, 1); assert.equal(summary.ambiguous, 0);
|
||||
const mapped = applyRoleMappings({ resources: [record] }, roles, { approveShared: true });
|
||||
assert.equal(mapped.resources[0].installMappings.length, 2);
|
||||
for (const role of ['staff', 'management']) {
|
||||
const mapping = approvedMapping(mapped.resources[0], role);
|
||||
assert.ok(mapping); assert.equal(mapping.destinationInstalled, false);
|
||||
assert.equal(mapping.destinationAccessVerified, false);
|
||||
}
|
||||
});
|
||||
test('private collector validates binary, reuses hash, rejects errors and unapproved redirects', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-private-test-'));
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user