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

+56 -43
View File
@@ -23,71 +23,84 @@ approved release and protected-resource installation commands.
## Source and private collection ## Source and private collection
Read-only legacy footer source: The 42 approved non-PHI resources have already been collected under the ignored
`/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt`. `private/employee-portal/hubfs/` workspace. Collection and clinical review are
The original stays outside Git as rollback evidence. No access code was available complete. Do not run a new collection pass for production preparation.
to the agent. The exact resource count and real content compatibility remain
unverified. Do not infer the earlier approximate 43 links as a measured count.
From `www`, in the owner's private interactive terminal: Production preparation is an owner-only, hidden-prompt sequence. The first
command identifies the encrypted streams from the staff and management codes;
the codes are never accepted in arguments, environment variables, files or
output:
```sh ```sh
node tools/employee-portal-migrate.mjs collect \ npm run portal:prepare -- identify \
/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt
npm run portal:prepare -- propose-mappings \
/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt /home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt
``` ```
Enter each legitimate view code at its hidden prompt. Never place codes in Review the private mapping summary. The role rule maps staff-only references to
arguments, environment variables, shell history or reports. The utility checks `/staff-resources/`, management-only references to `/management-resources/`, and
both streams, keeps plaintext in memory, and collects only referenced `/hubfs/` duplicates a shared source into both protected namespaces. After the owner
resources from the verified source origin. Other origins require explicit approves that operational policy, run:
review/code allowlisting; redirects are bounded and never inherit credentials.
No Jotform, Google or other vendor forms are fetched. ```sh
npm run portal:prepare -- approve-mappings \
/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt
npm run portal:prepare -- rewrite-production \
/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt
```
The rewrite command prompts for both codes again, rewrites every approved legacy
resource URL, re-encrypts with fresh salts and IVs, verifies each role privately,
and atomically installs `approved-production-ciphertext.json` plus a matching
private `production-readiness.json`. It refuses unresolved `/hubfs/` references.
Prior manifests and production ciphertext are retained as timestamped private
backups.
### Mapping approval versus server verification
`mappingApproved`, `accessPolicyApproved`, and `rewriteApproved` mean the
verified collected source has an approved role and safe protected destination
and may be prepared or installed. They do not claim the destination exists.
`destinationInstalled`, `destinationHashVerified`,
`destinationPermissionsVerified`, and `destinationAccessVerified` remain false
until an authorized VPS rehearsal actually installs the file and verifies its
bytes, `root:nginx` permissions, Basic Auth boundary and protected URL. Local
preparation and installation accept the first state; release acceptance must
still complete the second state before browser cutover.
Read-only legacy footer source:
`/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt`.
The original stays outside Git as rollback evidence. The 42-file collection and
its accepted content are complete; no current command downloads resources.
Private output is `private/employee-portal/`: directories 0700, manifests and Private output is `private/employee-portal/`: directories 0700, manifests and
binary resources 0600. This exact subtree is gitignored and outside Astro's binary resources 0600. This exact subtree is gitignored and outside Astro's
public directory. Filenames are SHA-256 hashes of source URLs, independently public directory. Filenames are SHA-256 hashes of source URLs, independently
verified by content hashes. The manifest retains original references, labels, verified by content hashes. The manifest retains original references, labels,
view membership, MIME, bytes, status and delivery/classification decisions. view membership, MIME, bytes, status and delivery/classification decisions.
All new resources default to UNRESOLVED. Publicly reachable does not mean public The manifest preserves the original source URL, collection hash, size, reference
approved. Resuming verifies an existing file's content hash; mismatches are not membership and accepted source evidence. Public legacy reachability did not
overwritten. HTML error documents, executable responses, invalid known signatures, infer public approval.
redirects outside approved destinations and resources over 50 MiB are rejected.
The first pass allows only the verified site origin. If a legitimate `/hubfs/`
response redirects to a HubSpot CDN, inspect that exact HTTPS origin first, then
add its origin to the private manifest's `approvedOrigins` list and rerun. Never
approve a redirect based only on a failed download or a broadly shared vendor
domain. Failures remain private review items. The utility prints counts only.
Before every artifact transfer, verify that only `dist` is copied and that it Before every artifact transfer, verify that only `dist` is copied and that it
contains no private manifest, document or real ciphertext unless that ciphertext contains no private manifest, document or real ciphertext unless that ciphertext
has separately been authorized for the local preview. Never archive the whole has separately been authorized for the local preview. Never archive the whole
working directory as a deployable artifact. working directory as a deployable artifact.
## Approved rewriting ## Historical collection and rewriting recovery notes
The owner classifies records as PUBLIC-APPROVED, STAFF-INTERNAL, The original source, collection manifests, collected bytes and prior ciphertext
MANAGEMENT-INTERNAL or UNRESOLVED in the private manifest. Set `deliveryPath`, revisions remain recovery evidence. Do not change their classifications or
`rewriteApproved`, `destinationImplemented`, `destinationVerified`, and (for destination flags by hand; use the production-preparation sequence above so
internal documents) `destinationAccessControlled` only with actual evidence. stream identity, references, source hashes and protected destinations remain
The utility does not create those destinations or infer their approval. consistent. CSS/srcset dependencies remain an explicit error rather than a
silent rewrite.
```sh
node tools/employee-portal-migrate.mjs rewrite \
/home/alice/projects/wrk/aia/local-archives/aia-employee-portal-hubspot-footer-source.txt
```
It reauthenticates both views, changes approved quoted href/src attributes only,
preserves unrelated HTML/labels, and re-encrypts with fresh salts and IVs. It
verifies round-trip decryption and distinct codes before writing new ciphertext
with exclusive filenames. The original source is untouched. CSS/srcset mappings
require separate review and are rejected rather than silently rewritten.
Unresolved links retain their original destination as a recorded dependency,
not a claim of verified destination health. A changed manifest alone never
changes links in the encrypted stream.
## Local preview and content safety ## Local preview and content safety
Use a private ciphertext file generated by `collect` or `rewrite`: Use a retained private ciphertext revision:
```sh ```sh
AIA_PORTAL_LOCAL_PREVIEW_APPROVED=true \ AIA_PORTAL_LOCAL_PREVIEW_APPROVED=true \
+9 -4
View File
@@ -61,7 +61,6 @@ npm run deploy:rollback -- FULL_APPROVED_SHA --confirm
## Employee resources: separate one-time operation ## Employee resources: separate one-time operation
Approved non-PHI employee files are never part of an ordinary release. After Approved non-PHI employee files are never part of an ordinary release. After
the website release that contains `portal-resource-install.sh` is promoted and
the VPS SOPS-backed Basic Auth material is independently ready: the VPS SOPS-backed Basic Auth material is independently ready:
```sh ```sh
@@ -72,15 +71,21 @@ AIA_DEPLOY_SSH_IDENTITY="$HOME/.ssh/AIAMarketing" \
npm run deploy:portal-resources -- --confirm npm run deploy:portal-resources -- --confirm
``` ```
The installer invokes the promoted root-only `portal-resource-install.sh` through For the first install, the client extracts `portal-resource-install.sh` from the
NixOS's base `/bin/sh` and coreutils path; it does not depend on a globally current reviewed Git commit, uploads it into the root-controlled commit staging
installed Node.js runtime. It reads only the private collection manifest, directory, verifies its SHA-256 remotely, and invokes it through NixOS's base
`/bin/sh` and coreutils path. A prior public site release and a globally installed
Node.js runtime are not prerequisites. It reads only the private collection manifest,
accepts approved `STAFF-INTERNAL` and `MANAGEMENT-INTERNAL` mappings to accepts approved `STAFF-INTERNAL` and `MANAGEMENT-INTERNAL` mappings to
`/staff-resources/` and `/management-resources/`, verifies every local hash, rejects links/traversal and `/staff-resources/` and `/management-resources/`, verifies every local hash, rejects links/traversal and
uploads a root-controlled temporary tree. It never uses `--delete` for resource uploads a root-controlled temporary tree. It never uses `--delete` for resource
directories and refuses to replace a different existing file. The VPS installs directories and refuses to replace a different existing file. The VPS installs
regular files as `root:nginx` mode `0640` and reports metadata only. regular files as `root:nginx` mode `0640` and reports metadata only.
Local mapping approval authorizes preparation and installation only. After the
authorized VPS rehearsal, verify installed hashes, ownership/mode, staff versus
management Basic Auth, and both protected URLs before deploying portal links.
Before public DNS cutover, test the site with approved host-header/SNI methods, Before public DNS cutover, test the site with approved host-header/SNI methods,
including the portal’s noindex response, valid ciphertext unlock, both protected including the portal’s noindex response, valid ciphertext unlock, both protected
resource namespaces, denial cases, redirects, Form A, IPv4 and IPv6. Portal resource namespaces, denial cases, redirects, Form A, IPv4 and IPv6. Portal
+1
View File
@@ -13,6 +13,7 @@
"deploy": "node tools/deploy.mjs", "deploy": "node tools/deploy.mjs",
"deploy:rollback": "node tools/deploy-rollback.mjs", "deploy:rollback": "node tools/deploy-rollback.mjs",
"deploy:portal-resources": "node tools/deploy-portal-resources.mjs", "deploy:portal-resources": "node tools/deploy-portal-resources.mjs",
"portal:prepare": "node tools/employee-portal-migrate.mjs",
"preview": "astro preview", "preview": "astro preview",
"extract:blog-footers": "node tools/extract-blog-footers.mjs", "extract:blog-footers": "node tools/extract-blog-footers.mjs",
"crawl:live": "node tools/crawl-live-site.mjs", "crawl:live": "node tools/crawl-live-site.mjs",
+18
View File
@@ -21,6 +21,24 @@ private manifests and resources must never enter Git, `dist`, source snapshots,
logs or deployment manifests. `/employee-portal` remains noindex and excluded logs or deployment manifests. `/employee-portal` remains noindex and excluded
from sitemap/hreflang. Form A, referral and publication behavior are unchanged. from sitemap/hreflang. Form A, referral and publication behavior are unchanged.
### Portal resource preparation correction — 2026-11-04
The resource schema now separates approved source-to-role mapping from actual
VPS installation evidence. An approved mapping may be prepared and installed
while all server-installed/hash/permission/authentication/URL verification flags
remain false. Shared resources can be installed from one verified source into
both protected namespaces without weakening management isolation. The owner-only
private workflow identifies the two encrypted streams via hidden prompts,
derives deterministic role mappings, rewrites all legacy employee `/hubfs/`
links, and creates a hash-bound private readiness record. A real production
ciphertext cannot pass `npm run deploy -- --dry-run` merely because it is
structurally valid.
The first resource install no longer depends on a previously promoted site
release: the reviewed POSIX installer is staged from the exact Git commit and
verified by SHA-256 before root execution. Server installation, SOPS-backed
Basic Auth and protected URL verification remain future VPS-rehearsal gates.
## Current referral decision — 2026-11-01 (supersedes Form C planning) ## Current referral decision — 2026-11-01 (supersedes Form C planning)
The owner directs `/referrals` to retain the current public-site workflow: one The owner directs `/referrals` to retain the current public-site workflow: one
+37 -21
View File
@@ -6,6 +6,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { rsyncArguments, sshTarget } from './deploy.mjs'; import { rsyncArguments, sshTarget } from './deploy.mjs';
import { mappingsFor, approvedMapping } from './portal-production.mjs';
const toolRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const toolRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repositoryRoot = path.resolve(toolRoot, '..'); const repositoryRoot = path.resolve(toolRoot, '..');
@@ -19,14 +20,18 @@ function run(command, args, { quiet = false } = {}) {
if (!quiet && result.stdout) process.stdout.write(result.stdout); if (!quiet && result.stdout) process.stdout.write(result.stdout);
return result.stdout.trim(); 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 safeName(value) { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,240}$/.test(value) && value !== '.' && value !== '..'; }
function roleAndName(record) { function roleAndName(record, mapping) {
if (!['STAFF-INTERNAL', 'MANAGEMENT-INTERNAL'].includes(record.classification)) fail('Every installed resource must have an approved internal classification'); const role = mapping?.role;
const role = record.classification === 'STAFF-INTERNAL' ? 'staff' : 'management'; if (!['staff', 'management'].includes(role) || !approvedMapping(record, role)) fail('Resource mapping is not approved for installation');
const prefix = `/${role}-resources/`; 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 = mapping.deliveryPath.slice(prefix.length);
const name = record.deliveryPath.slice(prefix.length); if (!safeName(name) || mapping.deliveryPath !== `${prefix}${name}`) fail('Resource destination path is unsafe');
if (!safeName(name) || record.deliveryPath !== `${prefix}${name}`) fail('Resource destination path is unsafe');
return { role, name }; return { role, name };
} }
export async function prepareResourcePlan(privateRoot, staging, sourceCommit) { 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'); if (!Array.isArray(manifest.resources)) fail('Private resource manifest has no resource list');
const seen = new Set(), files = []; const seen = new Set(), files = [];
for (const record of manifest.resources) { for (const record of manifest.resources) {
if (!['STAFF-INTERNAL','MANAGEMENT-INTERNAL'].includes(record.classification)) continue; for (const mapping of mappingsFor(record)) {
const { role, name } = roleAndName(record); if (!['staff', 'management'].includes(mapping.role)) continue;
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'); const { role, name } = roleAndName(record, mapping);
if (seen.has(`${role}/${name}`)) fail('Two resources map to the same protected destination'); 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');
seen.add(`${role}/${name}`); if (seen.has(`${role}/${name}`)) fail('Two resources map to the same protected destination');
const source = path.join(privateRoot, 'hubfs', record.localFile); seen.add(`${role}/${name}`);
const stat = await fs.lstat(source).catch(() => null); const source = path.join(privateRoot, 'hubfs', record.localFile);
if (!stat?.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) fail(`Approved resource is missing or unsafe: ${role}/${name}`); const stat = await fs.lstat(source).catch(() => null);
const bytes = await fs.readFile(source); if (!stat?.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) fail(`Approved resource is missing or unsafe: ${role}/${name}`);
if (bytes.length !== record.size || hash(bytes) !== record.sha256) fail(`Approved resource hash mismatch: ${role}/${name}`); const bytes = await fs.readFile(source);
const destination = path.join(staging, role); if (bytes.length !== record.size || hash(bytes) !== record.sha256) fail(`Approved resource hash mismatch: ${role}/${name}`);
await fs.mkdir(destination, { recursive: true, mode: 0o700 }); const destination = path.join(staging, role);
await fs.copyFile(source, path.join(destination, record.localFile), fs.constants.COPYFILE_EXCL); await fs.mkdir(destination, { recursive: true, mode: 0o700 });
files.push({ role, name, stageName: record.localFile, sha256: record.sha256, size: record.size }); 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'); 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}`)) }; 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 host = process.env.AIA_DEPLOY_HOST, identity = process.env.AIA_DEPLOY_SSH_IDENTITY;
const [ssh, ...sshOptions] = sshTarget(host, identity); const [ssh, ...sshOptions] = sshTarget(host, identity);
const remoteStage = `${root}/staging/portal-resources-${sha}`, remotePlan = `/root/aia-portal-resources-${sha}.tsv`; 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(' ')}`; const transport = `${ssh} ${sshOptions.map((item) => `'${item.replaceAll("'", "'\\''")}'`).join(' ')}`;
run(ssh, [...sshOptions, host, `install -d -m 0700 ${remoteStage}/staff ${remoteStage}/management`]); run(ssh, [...sshOptions, host, `install -d -m 0700 ${remoteStage}/staff ${remoteStage}/management`]);
for (const role of ['staff','management']) { for (const role of ['staff','management']) {
@@ -94,7 +108,9 @@ async function main() {
run('rsync', args); run('rsync', args);
} }
run('rsync', ['-rlt', '--chown=root:root', '--chmod=Fu=rw,Fgo=', '-e', transport, planFile, `${host}:${remotePlan}`]); 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)); console.log(JSON.stringify({ installed: true, sourceCommit: sha, resources: plan.files.length }, null, 2));
} finally { await fs.rm(temporary, { recursive: true, force: true }); } } finally { await fs.rm(temporary, { recursive: true, force: true }); }
} }
+17 -3
View File
@@ -27,7 +27,7 @@ async function writeApprovedResource(privateRoot, hubfs, bytes = Buffer.from('sy
const localFile = 'a'.repeat(64); const localFile = 'a'.repeat(64);
await fs.writeFile(path.join(hubfs, localFile), bytes, { mode: 0o600 }); await fs.writeFile(path.join(hubfs, localFile), bytes, { mode: 0o600 });
await fs.writeFile(path.join(privateRoot, 'manifest.json'), JSON.stringify({ resources: [{ 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 }; 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(plan.files.length, 1);
assert.equal((await fs.readFile(path.join(stage, 'staff', localFile))).toString(), bytes.toString()); 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/); 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 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), /destination/); 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 }); } } finally { await fs.rm(root, { recursive: true, force: true }); }
}); });
+8 -7
View File
@@ -9,8 +9,9 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process'; 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 { verifyArtifact } from './phase10-artifact-verify.mjs';
import { assertProductionReadiness } from './portal-production.mjs';
const toolRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const toolRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repositoryRoot = path.resolve(toolRoot, '..'); 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}`); for (const command of ['git', 'tar', 'ssh', 'rsync']) if (!commandExists(command)) fail(`Required deployment tool is unavailable: ${command}`);
return sha; 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) { async function syntheticPayload(directory) {
const payload = { alg: portalAlgorithm, streams: [ const payload = { alg: portalAlgorithm, streams: [
await encryptPortalStream('synthetic-staff', '<p>Synthetic portal fixture.</p>', 'synthetic-staff-code'), 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-')); const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-deploy-'));
try { try {
const configured = process.env.AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE; 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 && 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'); 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' }; 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' };
+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 fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { decryptPortalStream, encryptPortalStream, validatePortalPayload } from '../src/utils/portal-crypto.mjs'; 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) { async function hiddenPrompt(label) {
if (!process.stdin.isTTY || !process.stdin.setRawMode) throw new Error('An interactive private terminal is required'); if (!process.stdin.isTTY || !process.stdin.setRawMode) throw new Error('An interactive private terminal is required');
process.stderr.write(label); process.stderr.write(label);
@@ -24,67 +28,123 @@ async function hiddenPrompt(label) {
process.stdin.on('data', onData); process.stdin.on('data', onData);
}); });
} }
async function main() { async function readJson(file) { return JSON.parse(await fs.readFile(file, 'utf8')); }
const [command, source, ...extra] = process.argv.slice(2); async function atomicPrivateJson(file, value) {
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'); const temporary = `${file}.tmp-${process.pid}`;
// No scanning for credentials. The original is read only; never copied into Git. await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
const payload = validatePortalPayload(extractPayload(await fs.readFile(source, 'utf8'))); await fs.chmod(temporary, 0o600);
await fs.mkdir(root, { recursive: true, mode: 0o700 }); await fs.rename(temporary, file);
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));
} }
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; });
+127
View File
@@ -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 };
}
+8 -6
View File
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { approvedMapping } from './portal-production.mjs';
export const sourceOrigin = 'https://www.azinstitute4autism.com'; export const sourceOrigin = 'https://www.azinstitute4autism.com';
export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex'); 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'); for (const match of html.matchAll(/url\(\s*['"]?([^)'"\s]+)['"]?\s*\)/g)) record(match[1], '', 'css-url');
return references; return references;
} }
export function rewriteResources(html, records) { export function rewriteResources(html, records, role) {
const mappings = new Map(); const mappings = new Map();
for (const record of records) { for (const record of records) {
if (!record.rewriteApproved) continue; const mapping = role ? approvedMapping(record, role) : null;
if (!record.destinationImplemented || !record.destinationVerified || !record.deliveryPath?.startsWith('/') || record.deliveryPath.startsWith('//') || /[?<>"'\s]/.test(record.deliveryPath)) throw new Error('Unverified resource mapping'); // Public links use their legacy, explicitly approved single-path contract.
if (!['PUBLIC-APPROVED', 'STAFF-INTERNAL', 'MANAGEMENT-INTERNAL'].includes(record.classification)) throw new Error('Unclassified resource mapping'); 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 (record.classification !== 'PUBLIC-APPROVED' && !record.destinationAccessControlled) throw new Error('Internal destination lacks access control'); if (!mapping && !publicMapping) continue;
const deliveryPath = mapping?.deliveryPath ?? record.deliveryPath;
for (const ref of record.references) { for (const ref of record.references) {
if (ref.attribute === 'css-url' || ref.attribute === 'srcset') throw new Error('CSS/srcset rewriting requires separate review'); 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; let count = 0;
+20 -3
View File
@@ -10,6 +10,7 @@ import { decryptPortalStream, encryptPortalStream, validatePortalPayload, portal
import { collectResource, resourceReferences, rewriteResources } from './portal-resources.mjs'; import { collectResource, resourceReferences, rewriteResources } from './portal-resources.mjs';
import { isFormACompletion } from '../src/utils/jotform-completion.ts'; import { isFormACompletion } from '../src/utils/jotform-completion.ts';
import { portalLinkPresentation } from '../src/utils/portal-presentation.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', () => { test('completion envelope requires an exact approved origin, active frame window and string Form A ID', () => {
const source = {}; 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 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'); const refs = resourceReferences(html, 'synthetic');
assert.equal(refs.length, 2); assert.equal(new Set(refs.map((r) => r.sourceUrl)).size, 1); 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' }; const record = { references: refs, rewriteApproved: true, mappingApproved: true, deliveryPath: '/staff-resources/example.pdf', classification: 'STAFF-INTERNAL' };
assert.throws(() => rewriteResources(html, [record])); assert.equal(rewriteResources(html, [record], 'staff').html, html);
const result = rewriteResources(html, [{ ...record, destinationAccessControlled: true }]); 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(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); 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 () => { 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-')); const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-private-test-'));
try { try {