feat(deploy): add Contabo release workflow and portal payload gate

This commit is contained in:
DeCentN2Madness authored and AIAMarketing committed 2026-11-05 00:40:18 -07:00
1 parent 5fa54719b2
commit ba93a54942
18 files changed
+697 -28

No files matched your search

+4 -1
View File
@@ -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
+10
View File
@@ -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
+20 -4
View File
@@ -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
+7 -7
View File
@@ -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.
+87
View File
@@ -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-<sha>` and
`staging/dist-<sha>` 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.
+3
View File
@@ -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",
+21
View File
@@ -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
@@ -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';
---
+14 -4
View File
@@ -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'); }
}
+92
View File
@@ -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; });
@@ -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 }); }
});
+24
View File
@@ -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; }
}
+197
View File
@@ -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', '<p>Synthetic portal fixture.</p>', 'synthetic-staff-code'),
await encryptPortalStream('synthetic-management', '<p>Synthetic portal fixture.</p>', '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; });
}
+35
View File
@@ -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'), '<title>Synthetic</title>');
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/);
});
+27 -4
View File
@@ -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(/<script[^>]*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 (/<script[^>]*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;
}
+23 -1
View File
@@ -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'), '<script type="application/json" data-portal-payload>{"streams":[]}</script>');
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', '<p>Synthetic staff</p>', 'synthetic-staff-code'),
await encryptPortalStream('management', '<p>Synthetic management</p>', 'synthetic-management-code'),
] };
const portal = path.join(root, 'employee-portal/index.html');
await fs.mkdir(path.dirname(portal), { recursive: true });
await fs.writeFile(portal, '<!doctype html><html><head><title>Portal</title><meta name="robots" content="noindex,follow"><link rel="canonical" href="https://www.azinstitute4autism.com/employee-portal"></head><body><script data-portal-payload type="application/json">' + JSON.stringify(payload) + '</script></body></html>');
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, '<!-- changed -->');
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, '<!doctype html><html><head><title>Portal</title><meta name="robots" content="noindex,follow"><link rel="canonical" href="https://www.azinstitute4autism.com/employee-portal"></head><body><script data-portal-payload type="application/json">' + JSON.stringify(corrupted) + '</script></body></html>');
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();
+92
View File
@@ -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 };
+10 -5
View File
@@ -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, '<p>Synthetic</p>', 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, '<h1>Not encrypted</h1>'); 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, '<h1>Not encrypted</h1>');
assert.throws(() => readPortalInput(options), /missing or malformed/);
} finally { await fs.rm(dir, { recursive: true }); }
});