diff --git a/www/.env.example b/www/.env.example index c653346..cc77fa0 100644 --- a/www/.env.example +++ b/www/.env.example @@ -10,7 +10,10 @@ PUBLIC_AIA_FORMS_MOCK_RESULT=success PUBLIC_AIA_FORM_A_PREVIEW=false PUBLIC_ALLOW_INDEXING=false -# Employee portal ciphertext is an opt-in local compatibility preview only. +# Employee portal ciphertext is a non-PUBLIC build-only input. # Never supply a passphrase through environment variables or build arguments. AIA_PORTAL_LOCAL_PREVIEW_APPROVED=false # AIA_PORTAL_CIPHERTEXT_FILE=/owner-controlled/private/ciphertext.json +# Explicit production-indexing input; it is never placed in Git or source snapshots. +AIA_PORTAL_PRODUCTION_APPROVED=false +# AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE=/absolute/private/employee-portal/approved-production-ciphertext.json diff --git a/www/README.md b/www/README.md index 7b9e07a..6f72f76 100644 --- a/www/README.md +++ b/www/README.md @@ -611,6 +611,16 @@ Verify matching output with use `--mode=indexing` for indexing output and `--forms=off` or `--forms=mock` for those explicit builds. No actual submissions run in automated tests. +## Static releases and employee portal + +The Contabo deployment interface is intentionally separate from NixOS changes. +Use [the release runbook](integrations/nixos/deploy.md) for `npm run deploy`, +dry-run, rollback, the encrypted production portal input and the separate +one-time employee-resource installer. Ordinary releases never copy employee +documents into `dist`; protected staff and management resources remain under +server-controlled Basic Auth namespaces. The accepted interim portal is +explicitly non-PHI. + ### Google Ads consultation-form conversion handoff The Google Ads conversion ID, label, and privacy-gated Astro integration diff --git a/www/integrations/employee-portal/README.md b/www/integrations/employee-portal/README.md index b9dda9e..1771f51 100644 --- a/www/integrations/employee-portal/README.md +++ b/www/integrations/employee-portal/README.md @@ -1,9 +1,25 @@ # Employee portal compatibility and private migration -Recorded 2026-10-30. Browser-side shared-code decryption is approved only for -local compatibility development. It is not authentication, individual revocation, -a secure session or a HIPAA compliance determination. Resources need separate -server authorization before cutover. +Recorded 2026-11-04. The clinical director accepted the collected, non-PHI +staff and management portal content and the interim shared-code presentation. +Browser-side decryption remains access friction rather than individual +authentication, revocation, a secure session, or a HIPAA determination. + +## Approved production ciphertext + +An indexing-enabled production build may include one owner-approved encrypted +two-stream payload when both of these non-public build inputs are set: + +```sh +AIA_PORTAL_PRODUCTION_APPROVED=true +AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE=/absolute/private/employee-portal/approved-production-ciphertext.json +``` + +The build rejects missing, malformed, symlinked, oversized, wrong-mode, or +simultaneous local-preview/production inputs. It never decrypts the payload at +build time. `/employee-portal` remains noindex, sitemap-excluded and +hreflang-excluded. See [the deployment runbook](../nixos/deploy.md) for the +approved release and protected-resource installation commands. ## Source and private collection diff --git a/www/integrations/nixos/README.md b/www/integrations/nixos/README.md index 4d16d1f..7cf5047 100644 --- a/www/integrations/nixos/README.md +++ b/www/integrations/nixos/README.md @@ -63,10 +63,10 @@ Test direct requests, alternate encodings, role crossover and rollback. - Portal HTML: shared-code browser decryption, offline-guessable access friction. - Documents: require server authorization even if URLs are encrypted in the UI. -The compatibility preview cannot grant production portal access. Before cutover, -classify every document, implement and verify its delivery destination, rewrite -and re-encrypt links, test two-view separation, rotate shared codes privately, -check the public artifact for private files, and obtain owner/security approval. -Back up encrypted configuration and documents separately from keys; rehearse -restore and rollback. Revoking a static release cannot retract ciphertext or -files already downloaded by visitors. Site cutover is still unauthorized. +The approved production portal build uses a non-public encrypted two-stream +payload and retains the same browser-side decryption boundary. Approved non-PHI +documents install separately into the VPS-owned staff/management directories; +they are never copied into `dist`. Verify both protected namespaces, two-view +separation, artifact exclusions, code rotation, restore and rollback before +cutover. Revoking a static release cannot retract ciphertext or files already +downloaded by visitors. Site cutover remains separately authorized. diff --git a/www/integrations/nixos/deploy.md b/www/integrations/nixos/deploy.md new file mode 100644 index 0000000..98f94aa --- /dev/null +++ b/www/integrations/nixos/deploy.md @@ -0,0 +1,87 @@ +# Contabo release operations + +This site publishes static content through the merged Contabo release manager. +It does not run `nixos-rebuild`, reload nginx, change DNS, or write directly to +`/srv/www/azinstitute4autism.com/www/dist`. + +## Required local tools and private inputs + +- Node.js 22+, Git, tar, SSH and rsync. +- A normal SSH identity accepted by the existing root receiver: + `AIA_DEPLOY_SSH_IDENTITY=/absolute/path/to/identity`. +- `AIA_DEPLOY_HOST=administrator@host` (or the approved equivalent). +- For a real public release, an owner-controlled, regular encrypted payload: + `AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE=/absolute/private/employee-portal/approved-production-ciphertext.json`. + +The payload remains outside Git and the source snapshot. It is browser ciphertext +only; neither access code nor decrypted portal HTML enters an Astro environment +variable, public resource directory, build log, or deployment manifest. + +## Commands + +From `www/`: + +```sh +npm run deploy -- --dry-run +``` + +Dry-run performs the local production build, tests, artifact verification, +manifest creation and source-snapshot preparation. With no private production +payload it uses a generated synthetic two-stream fixture and explicitly reports +that the result is not production-ready. It never opens SSH or rsync. + +```sh +AIA_DEPLOY_HOST='root@approved-host' \ +AIA_DEPLOY_SSH_IDENTITY="$HOME/.ssh/AIAMarketing" \ +AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE="$PWD/private/employee-portal/approved-production-ciphertext.json" \ +npm run deploy -- --confirm +``` + +The confirmed command builds with `PUBLIC_ALLOW_INDEXING=true`, Form A live and +counters off, then uploads only fresh commit-specific `staging/source-` and +`staging/dist-` directories. It runs, in order: + +```text +aia-website-release verify +aia-website-release sync-source +aia-website-release promote +``` + +The VPS verifies the manifest, promotes an immutable release by atomic symlink, +and retains the prior release. A successful rsync transfer alone is not success. + +Rollback requires the reviewed manifest retained at the server’s fixed path: + +```sh +AIA_DEPLOY_HOST='root@approved-host' \ +AIA_DEPLOY_SSH_IDENTITY="$HOME/.ssh/AIAMarketing" \ +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.mjs` is promoted and +the VPS SOPS-backed Basic Auth material is independently ready: + +```sh +npm run deploy:portal-resources -- --dry-run + +AIA_DEPLOY_HOST='root@approved-host' \ +AIA_DEPLOY_SSH_IDENTITY="$HOME/.ssh/AIAMarketing" \ +npm run deploy:portal-resources -- --confirm +``` + +The installer 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. + +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 +shared-code rotation is coordinated: update VPS SOPS inputs and privately +re-encrypt the browser payload together. This interim portal is non-PHI and is +not an employee identity system. diff --git a/www/package.json b/www/package.json index c02211c..dc03fc6 100644 --- a/www/package.json +++ b/www/package.json @@ -10,6 +10,9 @@ "check": "astro check", "build": "astro check && astro build", "build:sandbox": "NODE_OPTIONS=--require=./tools/localhost-dns.cjs astro check && NODE_OPTIONS=--require=./tools/localhost-dns.cjs astro build", + "deploy": "node tools/deploy.mjs", + "deploy:rollback": "node tools/deploy-rollback.mjs", + "deploy:portal-resources": "node tools/deploy-portal-resources.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 d68f568..5f7251a 100644 --- a/www/reports/SESSION_HANDOFF.md +++ b/www/reports/SESSION_HANDOFF.md @@ -1,5 +1,26 @@ # AIA Astro Migration — Living Session Handoff +## Deployment and employee-portal release preparation — 2026-11-04 + +The clinical director has accepted the collected non-PHI employee portal views, +their interim shared-code presentation and the approved employee resources. +This supersedes earlier collection/content-acceptance tasks. Deployment, +filesystem permissions, protected-resource authorization, DNS and cutover remain +separate technical gates. + +The site-side deployment workflow builds an indexing-enabled release with an +explicit non-public approved portal ciphertext input, validates the artifact and +Contabo-compatible manifest, uploads only commit-specific staging trees, then +uses `aia-website-release verify`, `sync-source`, and `promote`. It does not +rebuild NixOS or alter nginx. Employee documents are a separate owner-run, +hash-verified resource-installation operation into the VPS-owned protected +namespaces. See [the release runbook](../integrations/nixos/deploy.md). + +The browser payload remains ciphertext only; codes, plaintext portal content, +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. + ## 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/src/components/pages/EmployeePortal.astro b/www/src/components/pages/EmployeePortal.astro index 8d5ce4c..d3a28de 100644 --- a/www/src/components/pages/EmployeePortal.astro +++ b/www/src/components/pages/EmployeePortal.astro @@ -3,9 +3,11 @@ import { readPortalInput } from '../../utils/portal-input.mjs'; import Button from '../Button.astro'; // Non-PUBLIC, build-only ciphertext input. No passphrase is read by the build. const payload = readPortalInput({ - approved: import.meta.env.AIA_PORTAL_LOCAL_PREVIEW_APPROVED, + localApproved: import.meta.env.AIA_PORTAL_LOCAL_PREVIEW_APPROVED, + productionApproved: import.meta.env.AIA_PORTAL_PRODUCTION_APPROVED, indexing: import.meta.env.PUBLIC_ALLOW_INDEXING, - file: import.meta.env.AIA_PORTAL_CIPHERTEXT_FILE, + localFile: import.meta.env.AIA_PORTAL_CIPHERTEXT_FILE, + productionFile: import.meta.env.AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE, }); const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c') : 'null'; --- diff --git a/www/src/utils/portal-input.mjs b/www/src/utils/portal-input.mjs index 75b5be3..4792546 100644 --- a/www/src/utils/portal-input.mjs +++ b/www/src/utils/portal-input.mjs @@ -1,10 +1,20 @@ // Build-only reader; never imported by a browser script. import fs from 'node:fs'; import { validatePortalPayload } from './portal-crypto.mjs'; -export function readPortalInput({ approved, indexing, file }) { - if (approved !== 'true' || indexing !== 'false' || !file) return undefined; +/** Build-only reader. Local preview and approved public publication are distinct. */ +export function readPortalInput({ localApproved, productionApproved, indexing, localFile, productionFile }) { + const local = localApproved === 'true'; + const production = productionApproved === 'true'; + if (local && production) throw new Error('Choose either local-preview or production portal ciphertext, never both'); + const file = local ? localFile : production ? productionFile : undefined; + const permitted = (local && indexing === 'false') || (production && indexing === 'true'); + if (!permitted) { + if (local || production) throw new Error('Portal ciphertext approval does not match this publication mode'); + return undefined; + } + if (!file) throw new Error('Approved portal ciphertext file is required for this build'); try { - if (fs.statSync(file).size > 12_000_000) return undefined; + if (fs.lstatSync(file).isSymbolicLink() || fs.statSync(file).size > 12_000_000) throw new Error('Portal ciphertext must be a regular bounded file'); return validatePortalPayload(JSON.parse(fs.readFileSync(file, 'utf8'))); - } catch { return undefined; } + } catch { throw new Error('Approved portal ciphertext is missing or malformed'); } } diff --git a/www/tools/deploy-portal-resources.mjs b/www/tools/deploy-portal-resources.mjs new file mode 100644 index 0000000..30c501b --- /dev/null +++ b/www/tools/deploy-portal-resources.mjs @@ -0,0 +1,92 @@ +/** Prepare and optionally transfer already-approved employee resources. */ +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { rsyncArguments, sshTarget } from './deploy.mjs'; + +const toolRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const repositoryRoot = path.resolve(toolRoot, '..'); +const root = '/srv/www/azinstitute4autism.com'; +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const fullSha = /^[0-9a-f]{40}$/; +function fail(message) { throw new Error(message); } +function run(command, args, { quiet = false } = {}) { + const result = spawnSync(command, args, { cwd: toolRoot, encoding: 'utf8' }); + if (result.error || result.status !== 0) fail(`${command} failed${result.stderr ? `: ${result.stderr.trim()}` : ''}`); + if (!quiet && result.stdout) process.stdout.write(result.stdout); + return result.stdout.trim(); +} +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'; + 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'); + return { role, name }; +} +export async function prepareResourcePlan(privateRoot, staging, sourceCommit) { + if (!fullSha.test(sourceCommit)) fail('Full source Git SHA required'); + const manifestPath = path.join(privateRoot, 'manifest.json'); + const manifestStat = await fs.lstat(manifestPath).catch(() => null); + if (!manifestStat?.isFile() || manifestStat.isSymbolicLink() || manifestStat.size > 4_000_000) fail('Private employee resource manifest is unavailable or unsafe'); + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + 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 }); + } + 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}`)) }; +} +function parse(argv) { + const valid = new Set(['--dry-run','--confirm']); + if (!argv.length || argv.some((arg) => !valid.has(arg)) || (argv.includes('--dry-run') && argv.includes('--confirm'))) fail('Usage: npm run deploy:portal-resources -- --dry-run | npm run deploy:portal-resources -- --confirm'); + return { dryRun: argv.includes('--dry-run'), confirm: argv.includes('--confirm') }; +} +async function main() { + const options = parse(process.argv.slice(2)); + const sha = run('git', ['-C', repositoryRoot, 'rev-parse', 'HEAD'], { quiet: true }); + if (!fullSha.test(sha) || run('git', ['-C', repositoryRoot, 'status', '--porcelain=v1', '--untracked-files=all'], { quiet: true })) fail('Resource installation requires a clean committed worktree'); + const privateRoot = process.env.AIA_PORTAL_PRIVATE_ROOT || path.join(toolRoot, 'private', 'employee-portal'); + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-portal-resources-')); + try { + const staging = path.join(temporary, `portal-resources-${sha}`); + const plan = await prepareResourcePlan(privateRoot, staging, sha); + const planFile = path.join(temporary, `aia-portal-resources-${sha}.json`); + await fs.writeFile(planFile, JSON.stringify(plan, null, 2) + '\n', { mode: 0o600 }); + if (options.dryRun) { console.log(JSON.stringify({ dryRun: true, sourceCommit: sha, resources: plan.files.map(({ role, name, sha256, size }) => ({ role, name, sha256, size })) }, null, 2)); return; } + if (!options.confirm) fail('Refusing employee-file transfer without --confirm'); + 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}.json`; + 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']) { + const args = rsyncArguments(path.join(staging, role), `${host}:${remoteStage}/${role}`, { remove: false }); + args.splice(-2, 0, '-e', transport); + run('rsync', args); + } + run('rsync', ['-rlt', '--chown=root:root', '--chmod=Fu=rw,Fgo=', '-e', transport, planFile, `${host}:${remotePlan}`]); + run(ssh, [...sshOptions, host, `node ${root}/www/tools/portal-resource-install.mjs ${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 }); } +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch((error) => { console.error(`Employee resource installation stopped: ${error.message}`); process.exitCode = 1; }); diff --git a/www/tools/deploy-portal-resources.test.mjs b/www/tools/deploy-portal-resources.test.mjs new file mode 100644 index 0000000..919f3a7 --- /dev/null +++ b/www/tools/deploy-portal-resources.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { prepareResourcePlan } from './deploy-portal-resources.mjs'; + +const digest = (value) => createHash('sha256').update(value).digest('hex'); +test('resource plan copies only hash-verified approved protected resources', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-resource-plan-')); + try { + const hubfs = path.join(root, 'hubfs'), stage = path.join(root, 'stage'); + await fs.mkdir(hubfs, { recursive: true }); + const bytes = Buffer.from('synthetic non-phi document'); + const localFile = 'a'.repeat(64); + await fs.writeFile(path.join(hubfs, localFile), bytes, { mode: 0o600 }); + await fs.writeFile(path.join(root, '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, + }] })); + const plan = await prepareResourcePlan(root, stage, 'a'.repeat(40)); + assert.equal(plan.files.length, 1); + assert.equal((await fs.readFile(path.join(stage, 'staff', localFile))).toString(), bytes.toString()); + await fs.writeFile(path.join(root, 'manifest.json'), JSON.stringify({ resources: [{ classification: 'STAFF-INTERNAL', deliveryPath: '/staff-resources/../bad', localFile, sha256: digest(bytes), size: bytes.length }] })); + await assert.rejects(prepareResourcePlan(root, path.join(root, 'second'), 'a'.repeat(40)), /destination/); + } finally { await fs.rm(root, { recursive: true, force: true }); } +}); diff --git a/www/tools/deploy-rollback.mjs b/www/tools/deploy-rollback.mjs new file mode 100644 index 0000000..6b76b5f --- /dev/null +++ b/www/tools/deploy-rollback.mjs @@ -0,0 +1,24 @@ +/** Reactivate one retained, reviewed Contabo release. No build or NixOS action. */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { sshTarget } from './deploy.mjs'; + +const sha = /^[0-9a-f]{40}$/; +function fail(message) { throw new Error(message); } +function run(command, args) { + const result = spawnSync(command, args, { encoding: 'utf8' }); + if (result.error || result.status !== 0) fail(`${command} failed${result.stderr ? `: ${result.stderr.trim()}` : ''}`); + if (result.stdout) process.stdout.write(result.stdout); +} +function main() { + const [release, confirmation] = process.argv.slice(2); + if (!sha.test(release ?? '') || confirmation !== '--confirm' || process.argv.length !== 4) fail('Usage: npm run deploy:rollback -- FULL_APPROVED_SHA --confirm'); + const [ssh, ...options] = sshTarget(process.env.AIA_DEPLOY_HOST, process.env.AIA_DEPLOY_SSH_IDENTITY); + // The release manager verifies the stored release and manifest before its + // atomic symlink update. The SHA is strictly validated before interpolation. + run(ssh, [...options, process.env.AIA_DEPLOY_HOST, `aia-website-release rollback /root/aia-site-${release}.manifest.json && readlink /srv/www/azinstitute4autism.com/www/dist`]); +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { main(); } catch (error) { console.error(`Rollback stopped: ${error.message}`); process.exitCode = 1; } +} diff --git a/www/tools/deploy.mjs b/www/tools/deploy.mjs new file mode 100644 index 0000000..f9ff67e --- /dev/null +++ b/www/tools/deploy.mjs @@ -0,0 +1,197 @@ +/** + * AIA static-site publisher. This tool prepares a reviewed release locally and + * uses only the Contabo release-manager interface for remote verification and + * atomic promotion. It never writes the active www/dist symlink directly. + */ +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +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 { verifyArtifact } from './phase10-artifact-verify.mjs'; + +const toolRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const repositoryRoot = path.resolve(toolRoot, '..'); +const projectRoot = '/srv/www/azinstitute4autism.com'; +const fullSha = /^[0-9a-f]{40}$/; +const publicExtensions = new Set(['.html','.css','.js','.json','.xml','.txt','.svg','.png','.jpg','.jpeg','.webp','.gif','.ico','.avif','.woff','.woff2','.ttf','.otf','.pdf','.mp4','.webm','.mp3']); +const sourceRejectedParts = new Set(['.git','.github','node_modules','private','secrets','dist','.astro']); +const sourceRejectedNames = new Set(['.netrc','.npmrc','.pypirc','.yarnrc','.yarnrc.yml','auth.json','credentials','id_ed25519','id_rsa']); +const stableCompare = (a, b) => a < b ? -1 : a > b ? 1 : 0; + +function fail(message) { throw new Error(message); } +function sha256(bytes) { return createHash('sha256').update(bytes).digest('hex'); } +function run(command, args, { cwd = toolRoot, env = process.env, quiet = false } = {}) { + const result = spawnSync(command, args, { cwd, env, encoding: 'utf8' }); + if (result.error) fail(`${command} is unavailable: ${result.error.message}`); + if (!quiet && result.stdout) process.stdout.write(result.stdout); + if (result.status !== 0) fail(`${command} failed${result.stderr ? `: ${result.stderr.trim()}` : ''}`); + return result.stdout.trim(); +} +function commandExists(command) { return spawnSync(command, ['--version'], { stdio: 'ignore' }).status === 0; } +function safeRelative(name) { + const normalized = name.replaceAll(path.sep, '/'); + if (!normalized || normalized.startsWith('/') || normalized.split('/').some((part) => !part || part === '.' || part === '..' || /[\x00-\x1f\\";{}]/.test(part))) fail(`Unsafe inventory path: ${name}`); + return normalized; +} +function publicPath(name) { + const safe = safeRelative(name); + if ((safe.split('/').some((part) => part.startsWith('.')) && safe !== 'assets/media/.gitkeep') || (!publicExtensions.has(path.extname(safe).toLowerCase()) && safe !== 'assets/media/.gitkeep') || safe.split('/').some((part) => ['secrets','private','node_modules','.git'].includes(part.toLowerCase()))) fail(`Unexpected public artifact path: ${safe}`); + return safe; +} +function sourcePath(name) { + const safe = safeRelative(name); + const parts = safe.split('/'); + if (safe === 'merge-plan.md' || ['assets','releases','secure-resources','staging'].includes(parts[0]) || parts.some((part) => sourceRejectedParts.has(part.toLowerCase())) || (parts[0] === 'www' && parts[1] === 'dist') || safe === '.aia-source-state.json' || safe === '.aia-source-pending.json' || path.basename(safe) === '.env' || (path.basename(safe).startsWith('.env.') && path.basename(safe) !== '.env.example') || sourceRejectedNames.has(path.basename(safe).toLowerCase()) || ['.key','.pem','.p12','.pfx'].includes(path.extname(safe).toLowerCase())) fail(`Tracked path is not deployable source: ${safe}`); + return safe; +} +function treeSha256(files) { + const digest = createHash('sha256'); + for (const [name, meta] of Object.entries(files).sort(([a], [b]) => stableCompare(a, b))) digest.update(`${name}\0${meta.sha256}\n`); + return digest.digest('hex'); +} +export async function releaseManifest(dist, sourceCommit) { + if (!fullSha.test(sourceCommit)) fail('A full source Git SHA is required'); + const files = {}, directories = []; + async function walk(relative = '') { + const current = path.join(dist, relative); + for (const entry of (await fs.readdir(current, { withFileTypes: true })).sort((a, b) => stableCompare(a.name, b.name))) { + const child = relative ? `${relative}/${entry.name}` : entry.name; + const stat = await fs.lstat(path.join(dist, child)); + if (stat.isSymbolicLink() || !(stat.isDirectory() || stat.isFile()) || (stat.isFile() && stat.nlink !== 1)) fail(`Unsafe artifact entry: ${child}`); + if (stat.isDirectory()) { + const safe = safeRelative(child); + if (safe.split('/').some((part) => ['private','hubfs','staff-resources','management-resources','secrets'].includes(part.toLowerCase()))) fail(`Unexpected private artifact directory: ${safe}`); + directories.push(safe); await walk(child); continue; + } + publicPath(child); + const bytes = await fs.readFile(path.join(dist, child)); + if (bytes.includes(Buffer.from('PRIVATE KEY-----'))) fail(`Private-key material in artifact: ${child}`); + files[child] = { sha256: sha256(bytes), size: bytes.length }; + } + } + await walk(); + if (!files['index.html']) fail('Artifact is missing index.html'); + const sortedFiles = Object.fromEntries(Object.entries(files).sort(([a], [b]) => stableCompare(a, b))); + return { schema: 1, sourceCommit, treeSha256: treeSha256(sortedFiles), files: sortedFiles, directories: directories.sort(stableCompare), immutableAssets: Object.keys(sortedFiles).filter((name) => /^_astro\/[^/]+\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/.test(name)).sort(stableCompare) }; +} +export function rsyncArguments(source, destination, { remove = false } = {}) { + return ['-rlt', ...(remove ? ['--delete'] : []), '--links', '--chown=root:root', '--chmod=Du=rwx,Dgo=,Fu=rw,Fgo=', source.endsWith('/') ? source : `${source}/`, destination]; +} +export function sshTarget(host, identity) { + if (!/^[A-Za-z0-9_.:@\-\[\]]+$/.test(host ?? '')) fail('AIA_DEPLOY_HOST is invalid'); + if (!identity || !path.isAbsolute(identity)) fail('AIA_DEPLOY_SSH_IDENTITY must be an absolute path'); + return ['ssh', '-i', identity, '-o', 'IdentitiesOnly=yes']; +} +function git(args, options) { return run('git', ['-C', repositoryRoot, ...args], options); } +function assertPreflight({ requirePrimary = true } = {}) { + if (git(['rev-parse', '--is-inside-work-tree'], { quiet: true }) !== 'true') fail('Run deploy from the AIA repository worktree'); + if (requirePrimary && git(['branch', '--show-current'], { quiet: true }) !== 'faithful-astro-migration') fail('Deploy only from faithful-astro-migration after its reviewed commit is checked out'); + const sha = git(['rev-parse', 'HEAD'], { quiet: true }); + if (!fullSha.test(sha)) fail('Unable to determine full Git commit SHA'); + if (git(['status', '--porcelain=v1', '--untracked-files=all'], { quiet: true })) fail('Deploy requires a clean tracked and untracked worktree'); + const gitDir = git(['rev-parse', '--git-dir'], { quiet: true }); + for (const state of ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply']) { + if (spawnSync('test', ['-e', path.join(repositoryRoot, gitDir, state)]).status === 0) fail(`Deploy refused during Git ${state}`); + } + 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'), + await encryptPortalStream('synthetic-management', '

Synthetic portal fixture.

', 'synthetic-management-code'), + ] }; + const file = path.join(directory, 'synthetic-portal-ciphertext.json'); + await fs.writeFile(file, JSON.stringify(payload), { mode: 0o600 }); + return { file, payload, synthetic: true }; +} +async function createSourceSnapshot(directory, sourceCommit) { + const tracked = git(['ls-tree', '-r', '--name-only', sourceCommit], { quiet: true }).split('\n').filter(Boolean); + const included = [], excluded = []; + for (const name of tracked) { + try { sourcePath(name); included.push(name); } + catch (error) { + if (name === 'merge-plan.md' || name === '.github' || name.startsWith('.github/')) excluded.push(name); + else fail(error.message); + } + } + if (!included.includes('www/package.json') || !included.some((name) => name.startsWith('www/src/'))) fail('Sanitized source snapshot is incomplete'); + const archive = path.join(directory, 'source.tar'); + const result = spawnSync('git', ['-C', repositoryRoot, 'archive', '--format=tar', sourceCommit, '--', ...included], { maxBuffer: 512 * 1024 * 1024 }); + if (result.status !== 0) fail('git archive failed while producing the source snapshot'); + await fs.writeFile(archive, result.stdout, { mode: 0o600 }); + const snapshot = path.join(directory, `source-${sourceCommit}`); + await fs.mkdir(snapshot, { mode: 0o700 }); + run('tar', ['-xf', archive, '-C', snapshot]); + await fs.rm(archive); + return { snapshot, included: included.length, excluded }; +} +async function executeBuild(env) { + run('npm', ['run', 'check'], { env }); + run('node', ['--experimental-strip-types', '--test', 'tools/jotform-form-a.test.mjs', 'tools/service-portal.test.mjs', 'tools/phase10-artifact.test.mjs'], { env }); + run('npm', ['run', 'test:publication'], { env }); + run('npm', ['run', 'audit:links'], { env }); + run('npm', ['run', 'audit:images'], { env }); + run('npm', ['run', 'audit:blog'], { env }); + run('npm', ['run', 'build'], { env }); +} +function parseArgs(argv) { + const allowed = new Set(['--dry-run', '--confirm']); + if (argv.some((arg) => !allowed.has(arg))) fail('Usage: npm run deploy -- --dry-run | npm run deploy -- --confirm'); + if (argv.includes('--dry-run') && argv.includes('--confirm')) fail('Choose --dry-run or --confirm, not both'); + return { dryRun: argv.includes('--dry-run'), confirm: argv.includes('--confirm') }; +} +async function remoteDeploy({ host, identity, sha, source, dist, manifest }) { + const [ssh, ...sshOptions] = sshTarget(host, identity); + const sourceStage = `${projectRoot}/staging/source-${sha}`; + const distStage = `${projectRoot}/staging/dist-${sha}`; + const remoteManifest = `/root/aia-site-${sha}.manifest.json`; + run(ssh, [...sshOptions, host, `install -d -m 0700 ${sourceStage} ${distStage}`]); + const transport = `${ssh} ${sshOptions.map((item) => `'${item.replaceAll("'", "'\\''")}'`).join(' ')}`; + const transfer = (local, remote, remove) => { + const args = rsyncArguments(local, remote, { remove }); + args.splice(-2, 0, '-e', transport); + run('rsync', args); + }; + transfer(source, `${host}:${sourceStage}`, true); + transfer(dist, `${host}:${distStage}`, true); + run('rsync', ['-rlt', '--chown=root:root', '--chmod=Fu=rw,Fgo=', '-e', transport, manifest, `${host}:${remoteManifest}`]); + run(ssh, [...sshOptions, host, `aia-website-release verify ${distStage} ${remoteManifest} && aia-website-release sync-source ${sourceStage} ${sha} && aia-website-release promote ${distStage} ${remoteManifest} && readlink ${projectRoot}/www/dist`]); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const sha = assertPreflight({ requirePrimary: !options.dryRun }); + 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); + 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' }; + await executeBuild(env); + const portalSha256 = sha256(Buffer.from(JSON.stringify(portal.payload))); + const artifact = await verifyArtifact(path.join(toolRoot, 'dist'), 'indexing', 'live', { mode: portal.synthetic ? 'synthetic' : 'production', sha256: portal.synthetic ? undefined : portalSha256 }); + if (!artifact.passed) fail(`Artifact verification failed: ${artifact.errors.join('; ')}`); + const manifest = await releaseManifest(path.join(toolRoot, 'dist'), sha); + const manifestFile = path.join(temporary, `aia-site-${sha}.manifest.json`); + await fs.writeFile(manifestFile, JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 }); + const snapshot = await createSourceSnapshot(temporary, sha); + const summary = { sourceCommit: sha, treeSha256: manifest.treeSha256, files: Object.keys(manifest.files).length, directories: manifest.directories.length, sourceFiles: snapshot.included, intentionallyExcludedSourceFiles: snapshot.excluded, portal: portal.synthetic ? 'synthetic-dry-run-only' : 'approved-production-ciphertext', destination: projectRoot }; + if (options.dryRun) { console.log(JSON.stringify({ dryRun: true, ...summary }, null, 2)); return; } + await remoteDeploy({ host: process.env.AIA_DEPLOY_HOST, identity: process.env.AIA_DEPLOY_SSH_IDENTITY, sha, source: snapshot.snapshot, dist: path.join(toolRoot, 'dist'), manifest: manifestFile }); + console.log(JSON.stringify({ promoted: true, ...summary }, null, 2)); + } finally { await fs.rm(temporary, { recursive: true, force: true }); } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { console.error(`Deployment stopped: ${error.message}`); process.exitCode = 1; }); +} diff --git a/www/tools/deploy.test.mjs b/www/tools/deploy.test.mjs new file mode 100644 index 0000000..824b788 --- /dev/null +++ b/www/tools/deploy.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; +import { releaseManifest, rsyncArguments, sshTarget } from './deploy.mjs'; + +test('site manifest follows the Contabo schema and detects unsafe public output', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-deploy-manifest-')); + try { + await fs.mkdir(path.join(root, '_astro')); + await fs.writeFile(path.join(root, 'index.html'), 'Synthetic'); + await fs.writeFile(path.join(root, '_astro', 'entry.abcdefgh.js'), 'export {}'); + const manifest = await releaseManifest(root, 'a'.repeat(40)); + assert.deepEqual(Object.keys(manifest).sort(), ['directories','files','immutableAssets','schema','sourceCommit','treeSha256']); + assert.equal(manifest.schema, 1); + assert.equal(manifest.immutableAssets[0], '_astro/entry.abcdefgh.js'); + const expected = createHash('sha256').update(Object.entries(manifest.files).sort(([a], [b]) => a.localeCompare(b)).map(([name, meta]) => `${name}\0${meta.sha256}\n`).join('')).digest('hex'); + assert.equal(manifest.treeSha256, expected); + const serverTool = path.resolve(process.cwd(), '../../contabo/scripts/website-release.py'); + const parity = spawnSync('python3', [serverTool, 'manifest', root, 'a'.repeat(40)], { encoding: 'utf8' }); + assert.equal(parity.status, 0, parity.stderr); + assert.deepEqual(manifest, JSON.parse(parity.stdout)); + await fs.mkdir(path.join(root, 'private')); + await assert.rejects(releaseManifest(root, 'a'.repeat(40)), /Unexpected private artifact/); + } finally { await fs.rm(root, { recursive: true, force: true }); } +}); + +test('deployment transfer uses the Contabo ownership and mode interface', () => { + assert.deepEqual(rsyncArguments('/tmp/source', 'host:/srv/stage', { remove: true }).slice(0, 6), ['-rlt','--delete','--links','--chown=root:root','--chmod=Du=rwx,Dgo=,Fu=rw,Fgo=','/tmp/source/']); + assert.deepEqual(sshTarget('root@host.example', '/absolute/key').slice(0, 3), ['ssh','-i','/absolute/key']); + assert.throws(() => sshTarget('host;rm -rf /', '/absolute/key'), /invalid/); +}); diff --git a/www/tools/phase10-artifact-verify.mjs b/www/tools/phase10-artifact-verify.mjs index 0c9f89e..06c46ed 100644 --- a/www/tools/phase10-artifact-verify.mjs +++ b/www/tools/phase10-artifact-verify.mjs @@ -2,6 +2,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { createHash } from 'node:crypto'; import fg from 'fast-glob'; +import { validatePortalPayload } from '../src/utils/portal-crypto.mjs'; const readText = async (file) => fs.readFile(file, 'utf8'); @@ -36,6 +37,12 @@ function hasTerminalSlash(url) { return pathname !== '/' && pathname.endsWith('/'); } +function portalPayload(html) { + const match = html.match(/]*data-portal-payload[^>]*>([\s\S]*?)<\/script>/i); + if (!match || match[1].trim() === 'null') return undefined; + try { return validatePortalPayload(JSON.parse(match[1])); } catch { return null; } +} + const formAContactLandingUrls = new Map([ ['/contact', 'https://form.jotform.com/262946078314059?formContext=contact&language=en'], ['/es/contact', 'https://form.jotform.com/262946078314059?formContext=contact&language=es'], @@ -89,9 +96,11 @@ function unsafeSignals(html, allowApprovedFormA = false) { return signals; } -export async function verifyArtifact(root, mode = 'staging', forms = 'live') { +export async function verifyArtifact(root, mode = 'staging', forms = 'live', portal = { mode: 'none' }) { if (!['staging', 'indexing'].includes(mode)) throw new Error('Artifact mode must be staging or indexing; form availability is selected separately with --forms=live|off|mock.'); if (!['live', 'off', 'mock'].includes(forms)) throw new Error('Artifact forms must be live, off, or mock.'); + if (!portal || !['none', 'synthetic', 'production'].includes(portal.mode)) throw new Error('Portal artifact mode must be none, synthetic, or production.'); + if (portal.mode === 'production' && (!/^[0-9a-f]{64}$/.test(portal.sha256 ?? '') || mode !== 'indexing')) throw new Error('Production portal verification requires indexing mode and its expected payload SHA-256.'); const formAEnabled = forms === 'live'; const stagingPolicy = mode === 'staging'; const staticFormAUrls = formAEnabled ? formAStaticUrls : new Map(); @@ -142,7 +151,18 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live') { } if (expectedFormAUrl && deferredIframeSources(html).length) errors.push(`${route}: unexpected deferred frame`); if (expectedDeferredFormAUrl && iframeSources(html).length) errors.push(`${route}: drawer must not load before activation`); - if (/]*data-portal-payload[^>]*>\s*(?!null\s*<)[\s\S]*?\"streams\"[\s\S]*?<\/script>/i.test(html)) errors.push(`${route}: private portal ciphertext is not an approved release artifact`); + const payload = portalPayload(html); + if (payload === null) errors.push(`${route}: malformed portal ciphertext`); + if (payload && route !== '/employee-portal') errors.push(`${route}: portal ciphertext appears outside /employee-portal`); + if (route === '/employee-portal') { + if (portal.mode === 'none' && payload) errors.push(`${route}: private portal ciphertext is not approved for this artifact`); + if (portal.mode === 'production' && !payload) errors.push(`${route}: approved production portal ciphertext is missing`); + if (portal.mode === 'synthetic' && !payload) errors.push(`${route}: synthetic portal ciphertext is missing`); + if (payload && portal.mode === 'production') { + const actual = createHash('sha256').update(JSON.stringify(payload)).digest('hex'); + if (actual !== portal.sha256) errors.push(`${route}: portal ciphertext does not match the approved build input`); + } + } if (!title) errors.push(`${route}: missing title`); if (!canonical) errors.push(`${route}: missing canonical`); if (!robots) errors.push(`${route}: missing robots directive`); @@ -199,7 +219,7 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live') { const digest = createHash('sha256'); for (const file of files) { const relative = path.relative(root, file).replaceAll(path.sep, '/'); - if (/(?:^|\/)(?:private|hubfs)(?:\/|$)/i.test(relative)) errors.push('private/legacy portal resource directory in artifact'); + if (/(?:^|\/)(?:private|hubfs|staff-resources|management-resources)(?:\/|$)/i.test(relative)) errors.push('private/legacy portal resource directory in artifact'); if (/\.(?:html?|js|mjs|css|json|xml|txt)$/i.test(relative)) { const contents = await fs.readFile(file, 'utf8'); if (/262946078314059/i.test(contents)) formAResourceFiles.push(relative); @@ -221,6 +241,7 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live') { return { mode, forms, + portal: portal.mode, htmlRoutes: htmlFiles.length, sitemapUrls: sitemapRoutes.length, hreflangLinks, @@ -241,7 +262,9 @@ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(im const mode = process.argv.find((arg) => arg.startsWith('--mode='))?.slice('--mode='.length) ?? 'staging'; const root = process.argv.find((arg) => arg.startsWith('--root='))?.slice('--root='.length) ?? 'dist'; const forms = process.argv.find((arg) => arg.startsWith('--forms='))?.slice('--forms='.length) ?? 'live'; - const result = await verifyArtifact(path.resolve(root), mode, forms); + const portalMode = process.argv.find((arg) => arg.startsWith('--portal='))?.slice('--portal='.length) ?? 'none'; + const portalSha256 = process.argv.find((arg) => arg.startsWith('--portal-sha256='))?.slice('--portal-sha256='.length); + const result = await verifyArtifact(path.resolve(root), mode, forms, { mode: portalMode, sha256: portalSha256 }); console.log(JSON.stringify(result, null, 2)); if (!result.passed) process.exitCode = 1; } diff --git a/www/tools/phase10-artifact.test.mjs b/www/tools/phase10-artifact.test.mjs index ca7cf03..f689e8b 100644 --- a/www/tools/phase10-artifact.test.mjs +++ b/www/tools/phase10-artifact.test.mjs @@ -4,6 +4,8 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { verifyArtifact } from './phase10-artifact-verify.mjs'; +import { createHash } from 'node:crypto'; +import { encryptPortalStream, portalAlgorithm } from '../src/utils/portal-crypto.mjs'; async function fixture(mode = 'staging', forms = 'live') { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-phase10-artifact-')); @@ -139,10 +141,30 @@ test('release verification rejects private portal resources and encrypted payloa await fs.appendFile(path.join(root, 'index.html'), ''); const result = await verifyArtifact(root, 'staging'); assert.equal(result.passed, false); - assert.ok(result.errors.some((error) => error.includes('private portal ciphertext'))); + assert.ok(result.errors.some((error) => error.includes('portal ciphertext'))); assert.ok(result.errors.some((error) => error.includes('private/legacy'))); }); +test('approved production portal accepts only the expected two-stream ciphertext on the noindex route', async () => { + const root = await fixture('indexing'); + const payload = { alg: portalAlgorithm, streams: [ + await encryptPortalStream('staff', '

Synthetic staff

', 'synthetic-staff-code'), + await encryptPortalStream('management', '

Synthetic management

', 'synthetic-management-code'), + ] }; + const portal = path.join(root, 'employee-portal/index.html'); + await fs.mkdir(path.dirname(portal), { recursive: true }); + await fs.writeFile(portal, 'Portal'); + const sha256 = createHash('sha256').update(JSON.stringify(payload)).digest('hex'); + const result = await verifyArtifact(root, 'indexing', 'live', { mode: 'production', sha256 }); + assert.equal(result.passed, true, result.errors.join('; ')); + await fs.appendFile(portal, ''); + assert.equal((await verifyArtifact(root, 'indexing', 'live', { mode: 'production', sha256 })).passed, true); + const corrupted = JSON.parse(JSON.stringify(payload)); + corrupted.streams[0].ct = (corrupted.streams[0].ct[0] === 'A' ? 'B' : 'A') + corrupted.streams[0].ct.slice(1); + await fs.writeFile(portal, 'Portal'); + assert.equal((await verifyArtifact(root, 'indexing', 'live', { mode: 'production', sha256 })).passed, false); +}); + test('artifact rejects extra routes, wrong origins and prematurely loaded drawers', async () => { const root = await fixture(); diff --git a/www/tools/portal-resource-install.mjs b/www/tools/portal-resource-install.mjs new file mode 100644 index 0000000..29ee6fc --- /dev/null +++ b/www/tools/portal-resource-install.mjs @@ -0,0 +1,92 @@ +/** Root-only Contabo-side installer for a reviewed employee-resource plan. */ +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const root = '/srv/www/azinstitute4autism.com'; +const roles = new Set(['staff', 'management']); +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const safeName = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,240}$/.test(value) && value !== '.' && value !== '..'; + +function fail(message) { throw new Error(message); } +function nginxGroup() { + const result = spawnSync('getent', ['group', 'nginx'], { encoding: 'utf8' }); + const gid = Number(result.stdout?.trim().split(':')[2]); + if (!Number.isInteger(gid)) fail('nginx group is unavailable'); + return gid; +} +async function readPlan(file) { + const stat = await fs.lstat(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 2_000_000) fail('Resource plan must be a bounded regular file'); + const plan = JSON.parse(await fs.readFile(file, 'utf8')); + if (plan.schema !== 1 || !Array.isArray(plan.files) || !/^[0-9a-f]{40}$/.test(plan.sourceCommit ?? '')) fail('Invalid resource installation plan'); + const destinations = new Set(); + for (const entry of plan.files) { + if (!roles.has(entry.role) || !safeName(entry.name) || !safeName(entry.stageName) || !/^[0-9a-f]{64}$/.test(entry.sha256 ?? '') || !Number.isInteger(entry.size) || entry.size <= 0 || entry.size > 50 * 1024 * 1024) fail('Invalid resource plan entry'); + const key = `${entry.role}/${entry.name}`; + if (destinations.has(key)) fail('Duplicate resource destination'); + destinations.add(key); + } + return plan; +} +async function verifyStage(stage, plan) { + const expected = new Set(plan.files.map((entry) => `${entry.role}/${entry.stageName}`)); + const actual = new Set(); + for (const role of roles) { + const directory = path.join(stage, role); + const stat = await fs.lstat(directory).catch(() => null); + if (!stat || !stat.isDirectory() || stat.isSymbolicLink()) fail(`Missing safe ${role} staging directory`); + for (const name of await fs.readdir(directory)) { + const file = path.join(directory, name), relative = `${role}/${name}`, meta = await fs.lstat(file); + if (!meta.isFile() || meta.isSymbolicLink() || meta.nlink !== 1) fail(`Unsafe staged resource: ${relative}`); + actual.add(relative); + } + } + if (actual.size !== expected.size || [...actual].some((value) => !expected.has(value))) fail('Staged resources do not exactly match the reviewed plan'); + for (const entry of plan.files) { + const source = path.join(stage, entry.role, entry.stageName); + const bytes = await fs.readFile(source); + if (bytes.length !== entry.size || sha256(bytes) !== entry.sha256) fail(`Staged resource digest mismatch: ${entry.role}/${entry.name}`); + } +} +async function install(stage, plan) { + if (process.getuid?.() !== 0) fail('Resource installation requires root'); + const expectedStage = path.join(root, 'staging', `portal-resources-${plan.sourceCommit}`); + if (path.resolve(stage) !== expectedStage) fail('Resource stage is outside the approved root-controlled staging directory'); + await verifyStage(stage, plan); + const gid = nginxGroup(); + const installed = []; + for (const entry of plan.files) { + const source = path.join(stage, entry.role, entry.stageName); + const destinationDirectory = path.join(root, 'secure-resources', entry.role); + const destination = path.join(destinationDirectory, entry.name); + const existing = await fs.lstat(destination).catch(() => null); + if (existing) { + if (!existing.isFile() || existing.isSymbolicLink()) fail(`Unsafe existing resource destination: ${entry.role}/${entry.name}`); + const bytes = await fs.readFile(destination); + if (bytes.length !== entry.size || sha256(bytes) !== entry.sha256) fail(`Refusing to replace existing resource: ${entry.role}/${entry.name}`); + installed.push({ role: entry.role, name: entry.name, result: 'already-verified' }); + continue; + } + const temporary = path.join(destinationDirectory, `.${entry.name}.next-${process.pid}`); + await fs.copyFile(source, temporary, fs.constants.COPYFILE_EXCL); + await fs.chown(temporary, 0, gid); + await fs.chmod(temporary, 0o640); + await fs.rename(temporary, destination); + installed.push({ role: entry.role, name: entry.name, result: 'installed' }); + } + return installed; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === new URL(import.meta.url).pathname) { + const [stage, manifest] = process.argv.slice(2); + (async () => { + if (!stage || !manifest || process.argv.length !== 4) fail('Usage: portal-resource-install.mjs STAGING_DIRECTORY REVIEWED_PLAN.json'); + const plan = await readPlan(manifest); + const installed = await install(stage, plan); + console.log(JSON.stringify({ sourceCommit: plan.sourceCommit, installed: installed.length, identifiers: installed }, null, 2)); + })().catch((error) => { console.error(`Employee resource installation stopped: ${error.message}`); process.exitCode = 1; }); +} + +export { readPlan, verifyStage, install }; diff --git a/www/tools/service-portal.test.mjs b/www/tools/service-portal.test.mjs index 65d9487..b8594c2 100644 --- a/www/tools/service-portal.test.mjs +++ b/www/tools/service-portal.test.mjs @@ -127,21 +127,26 @@ test('private collector validates binary, reuses hash, rejects errors and unappr } finally { await fs.rm(dir, { recursive: true }); } }); -test('portal input requires every local gate and rejects missing or malformed ciphertext', async () => { +test('portal input isolates local preview from explicit indexing production payloads', async () => { const { readPortalInput } = await import('../src/utils/portal-input.mjs'); const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-portal-input-')); try { const pass = randomBytes(24).toString('base64'); const streams = await Promise.all(['one','two'].map((id) => encryptPortalStream(id, '

Synthetic

', pass))); const file = path.join(dir, 'fixture.json'); await fs.writeFile(file, JSON.stringify({ alg: portalAlgorithm, streams }), { mode: 0o600 }); - const options = { approved: 'true', indexing: 'false', file }; + const options = { localApproved: 'true', indexing: 'false', localFile: file }; assert.ok(readPortalInput(options)); // Form modes cannot substitute for the independent private-input approval. for (const mode of ['live', 'off', 'mock']) { assert.ok(readPortalInput({ ...options, mode })); - assert.equal(readPortalInput({ indexing: 'false', file, mode }), undefined); + assert.equal(readPortalInput({ indexing: 'false', localFile: file, mode }), undefined); } - for (const changed of [{ approved: undefined }, { approved: 'false' }, { indexing: 'true' }, { indexing: undefined }, { file: '/missing-fixture' }]) assert.equal(readPortalInput({ ...options, ...changed }), undefined); - await fs.writeFile(file, '

Not encrypted

'); assert.equal(readPortalInput(options), undefined); + for (const changed of [{ localApproved: undefined }, { localApproved: 'false' }]) assert.equal(readPortalInput({ ...options, ...changed }), undefined); + assert.throws(() => readPortalInput({ ...options, indexing: 'true' }), /publication mode/); + assert.ok(readPortalInput({ productionApproved: 'true', indexing: 'true', productionFile: file })); + assert.throws(() => readPortalInput({ productionApproved: 'true', indexing: 'true' }), /required/); + assert.throws(() => readPortalInput({ localApproved: 'true', productionApproved: 'true', indexing: 'false', localFile: file, productionFile: file }), /either/); + await fs.writeFile(file, '

Not encrypted

'); + assert.throws(() => readPortalInput(options), /missing or malformed/); } finally { await fs.rm(dir, { recursive: true }); } });