diff --git a/www/integrations/employee-portal/README.md b/www/integrations/employee-portal/README.md index 1771f51..3b9af83 100644 --- a/www/integrations/employee-portal/README.md +++ b/www/integrations/employee-portal/README.md @@ -23,71 +23,84 @@ approved release and protected-resource installation commands. ## Source and private collection -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. No access code was available -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. +The 42 approved non-PHI resources have already been collected under the ignored +`private/employee-portal/hubfs/` workspace. Collection and clinical review are +complete. Do not run a new collection pass for production preparation. -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 -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 ``` -Enter each legitimate view code at its hidden prompt. Never place codes in -arguments, environment variables, shell history or reports. The utility checks -both streams, keeps plaintext in memory, and collects only referenced `/hubfs/` -resources from the verified source origin. Other origins require explicit -review/code allowlisting; redirects are bounded and never inherit credentials. -No Jotform, Google or other vendor forms are fetched. +Review the private mapping summary. The role rule maps staff-only references to +`/staff-resources/`, management-only references to `/management-resources/`, and +duplicates a shared source into both protected namespaces. After the owner +approves that operational policy, run: + +```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 binary resources 0600. This exact subtree is gitignored and outside Astro's public directory. Filenames are SHA-256 hashes of source URLs, independently verified by content hashes. The manifest retains original references, labels, view membership, MIME, bytes, status and delivery/classification decisions. -All new resources default to UNRESOLVED. Publicly reachable does not mean public -approved. Resuming verifies an existing file's content hash; mismatches are not -overwritten. HTML error documents, executable responses, invalid known signatures, -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. +The manifest preserves the original source URL, collection hash, size, reference +membership and accepted source evidence. Public legacy reachability did not +infer public approval. Before every artifact transfer, verify that only `dist` is copied and that it contains no private manifest, document or real ciphertext unless that ciphertext has separately been authorized for the local preview. Never archive the whole working directory as a deployable artifact. -## Approved rewriting +## Historical collection and rewriting recovery notes -The owner classifies records as PUBLIC-APPROVED, STAFF-INTERNAL, -MANAGEMENT-INTERNAL or UNRESOLVED in the private manifest. Set `deliveryPath`, -`rewriteApproved`, `destinationImplemented`, `destinationVerified`, and (for -internal documents) `destinationAccessControlled` only with actual evidence. -The utility does not create those destinations or infer their approval. - -```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. +The original source, collection manifests, collected bytes and prior ciphertext +revisions remain recovery evidence. Do not change their classifications or +destination flags by hand; use the production-preparation sequence above so +stream identity, references, source hashes and protected destinations remain +consistent. CSS/srcset dependencies remain an explicit error rather than a +silent rewrite. ## Local preview and content safety -Use a private ciphertext file generated by `collect` or `rewrite`: +Use a retained private ciphertext revision: ```sh AIA_PORTAL_LOCAL_PREVIEW_APPROVED=true \ diff --git a/www/integrations/nixos/deploy.md b/www/integrations/nixos/deploy.md index e398c4d..bcec0a7 100644 --- a/www/integrations/nixos/deploy.md +++ b/www/integrations/nixos/deploy.md @@ -61,7 +61,6 @@ npm run deploy:rollback -- FULL_APPROVED_SHA --confirm ## Employee resources: separate one-time operation 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: ```sh @@ -72,15 +71,21 @@ AIA_DEPLOY_SSH_IDENTITY="$HOME/.ssh/AIAMarketing" \ npm run deploy:portal-resources -- --confirm ``` -The installer invokes the promoted root-only `portal-resource-install.sh` through -NixOS's base `/bin/sh` and coreutils path; it does not depend on a globally -installed Node.js runtime. It reads only the private collection manifest, +For the first install, the client extracts `portal-resource-install.sh` from the +current reviewed Git commit, uploads it into the root-controlled commit staging +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 `/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 directories and refuses to replace a different existing file. The VPS installs 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, including the portal’s noindex response, valid ciphertext unlock, both protected resource namespaces, denial cases, redirects, Form A, IPv4 and IPv6. Portal diff --git a/www/package.json b/www/package.json index dc03fc6..7a7b1c8 100644 --- a/www/package.json +++ b/www/package.json @@ -13,6 +13,7 @@ "deploy": "node tools/deploy.mjs", "deploy:rollback": "node tools/deploy-rollback.mjs", "deploy:portal-resources": "node tools/deploy-portal-resources.mjs", + "portal:prepare": "node tools/employee-portal-migrate.mjs", "preview": "astro preview", "extract:blog-footers": "node tools/extract-blog-footers.mjs", "crawl:live": "node tools/crawl-live-site.mjs", diff --git a/www/reports/SESSION_HANDOFF.md b/www/reports/SESSION_HANDOFF.md index 5f7251a..2f38110 100644 --- a/www/reports/SESSION_HANDOFF.md +++ b/www/reports/SESSION_HANDOFF.md @@ -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 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) The owner directs `/referrals` to retain the current public-site workflow: one diff --git a/www/tools/deploy-portal-resources.mjs b/www/tools/deploy-portal-resources.mjs index e059630..e513bbf 100644 --- a/www/tools/deploy-portal-resources.mjs +++ b/www/tools/deploy-portal-resources.mjs @@ -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 }); } } diff --git a/www/tools/deploy-portal-resources.test.mjs b/www/tools/deploy-portal-resources.test.mjs index 615b120..4a24b2e 100644 --- a/www/tools/deploy-portal-resources.test.mjs +++ b/www/tools/deploy-portal-resources.test.mjs @@ -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 }); } }); diff --git a/www/tools/deploy.mjs b/www/tools/deploy.mjs index 5d913b0..7af6ef3 100644 --- a/www/tools/deploy.mjs +++ b/www/tools/deploy.mjs @@ -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', '

Synthetic portal fixture.

', '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' }; diff --git a/www/tools/employee-portal-migrate.mjs b/www/tools/employee-portal-migrate.mjs index cc1aac2..b5a8c51 100644 --- a/www/tools/employee-portal-migrate.mjs +++ b/www/tools/employee-portal-migrate.mjs @@ -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; }); diff --git a/www/tools/portal-production.mjs b/www/tools/portal-production.mjs new file mode 100644 index 0000000..82c4a4b --- /dev/null +++ b/www/tools/portal-production.mjs @@ -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 }; +} diff --git a/www/tools/portal-resources.mjs b/www/tools/portal-resources.mjs index 757577c..1b3a54a 100644 --- a/www/tools/portal-resources.mjs +++ b/www/tools/portal-resources.mjs @@ -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; diff --git a/www/tools/service-portal.test.mjs b/www/tools/service-portal.test.mjs index b8594c2..c1f03d7 100644 --- a/www/tools/service-portal.test.mjs +++ b/www/tools/service-portal.test.mjs @@ -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 = '

Synthetic

ExampleAgainOther'; 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')); 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 {