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_AIA_FORM_A_PREVIEW=false
PUBLIC_ALLOW_INDEXING=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. # Never supply a passphrase through environment variables or build arguments.
AIA_PORTAL_LOCAL_PREVIEW_APPROVED=false AIA_PORTAL_LOCAL_PREVIEW_APPROVED=false
# AIA_PORTAL_CIPHERTEXT_FILE=/owner-controlled/private/ciphertext.json # 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` use `--mode=indexing` for indexing output and `--forms=off` or `--forms=mock`
for those explicit builds. No actual submissions run in automated tests. 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 ### Google Ads consultation-form conversion handoff
The Google Ads conversion ID, label, and privacy-gated Astro integration 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 # Employee portal compatibility and private migration
Recorded 2026-10-30. Browser-side shared-code decryption is approved only for Recorded 2026-11-04. The clinical director accepted the collected, non-PHI
local compatibility development. It is not authentication, individual revocation, staff and management portal content and the interim shared-code presentation.
a secure session or a HIPAA compliance determination. Resources need separate Browser-side decryption remains access friction rather than individual
server authorization before cutover. 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 ## 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. - Portal HTML: shared-code browser decryption, offline-guessable access friction.
- Documents: require server authorization even if URLs are encrypted in the UI. - Documents: require server authorization even if URLs are encrypted in the UI.
The compatibility preview cannot grant production portal access. Before cutover, The approved production portal build uses a non-public encrypted two-stream
classify every document, implement and verify its delivery destination, rewrite payload and retains the same browser-side decryption boundary. Approved non-PHI
and re-encrypt links, test two-view separation, rotate shared codes privately, documents install separately into the VPS-owned staff/management directories;
check the public artifact for private files, and obtain owner/security approval. they are never copied into `dist`. Verify both protected namespaces, two-view
Back up encrypted configuration and documents separately from keys; rehearse separation, artifact exclusions, code rotation, restore and rollback before
restore and rollback. Revoking a static release cannot retract ciphertext or cutover. Revoking a static release cannot retract ciphertext or files already
files already downloaded by visitors. Site cutover is still unauthorized. 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", "check": "astro check",
"build": "astro check && astro build", "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", "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", "preview": "astro preview",
"extract:blog-footers": "node tools/extract-blog-footers.mjs", "extract:blog-footers": "node tools/extract-blog-footers.mjs",
"crawl:live": "node tools/crawl-live-site.mjs", "crawl:live": "node tools/crawl-live-site.mjs",
+21
View File
@@ -1,5 +1,26 @@
# AIA Astro Migration — Living Session Handoff # 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) ## Current referral decision — 2026-11-01 (supersedes Form C planning)
The owner directs `/referrals` to retain the current public-site workflow: one The owner directs `/referrals` to retain the current public-site workflow: one
@@ -3,9 +3,11 @@ import { readPortalInput } from '../../utils/portal-input.mjs';
import Button from '../Button.astro'; import Button from '../Button.astro';
// Non-PUBLIC, build-only ciphertext input. No passphrase is read by the build. // Non-PUBLIC, build-only ciphertext input. No passphrase is read by the build.
const payload = readPortalInput({ 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, 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'; 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. // Build-only reader; never imported by a browser script.
import fs from 'node:fs'; import fs from 'node:fs';
import { validatePortalPayload } from './portal-crypto.mjs'; import { validatePortalPayload } from './portal-crypto.mjs';
export function readPortalInput({ approved, indexing, file }) { /** Build-only reader. Local preview and approved public publication are distinct. */
if (approved !== 'true' || indexing !== 'false' || !file) return undefined; 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 { 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'))); 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 path from 'node:path';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import fg from 'fast-glob'; import fg from 'fast-glob';
import { validatePortalPayload } from '../src/utils/portal-crypto.mjs';
const readText = async (file) => fs.readFile(file, 'utf8'); const readText = async (file) => fs.readFile(file, 'utf8');
@@ -36,6 +37,12 @@ function hasTerminalSlash(url) {
return pathname !== '/' && pathname.endsWith('/'); 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([ const formAContactLandingUrls = new Map([
['/contact', 'https://form.jotform.com/262946078314059?formContext=contact&language=en'], ['/contact', 'https://form.jotform.com/262946078314059?formContext=contact&language=en'],
['/es/contact', 'https://form.jotform.com/262946078314059?formContext=contact&language=es'], ['/es/contact', 'https://form.jotform.com/262946078314059?formContext=contact&language=es'],
@@ -89,9 +96,11 @@ function unsafeSignals(html, allowApprovedFormA = false) {
return signals; 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 (!['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 (!['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 formAEnabled = forms === 'live';
const stagingPolicy = mode === 'staging'; const stagingPolicy = mode === 'staging';
const staticFormAUrls = formAEnabled ? formAStaticUrls : new Map(); 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 (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 (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 (!title) errors.push(`${route}: missing title`);
if (!canonical) errors.push(`${route}: missing canonical`); if (!canonical) errors.push(`${route}: missing canonical`);
if (!robots) errors.push(`${route}: missing robots directive`); 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'); const digest = createHash('sha256');
for (const file of files) { for (const file of files) {
const relative = path.relative(root, file).replaceAll(path.sep, '/'); 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)) { if (/\.(?:html?|js|mjs|css|json|xml|txt)$/i.test(relative)) {
const contents = await fs.readFile(file, 'utf8'); const contents = await fs.readFile(file, 'utf8');
if (/262946078314059/i.test(contents)) formAResourceFiles.push(relative); if (/262946078314059/i.test(contents)) formAResourceFiles.push(relative);
@@ -221,6 +241,7 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live') {
return { return {
mode, mode,
forms, forms,
portal: portal.mode,
htmlRoutes: htmlFiles.length, htmlRoutes: htmlFiles.length,
sitemapUrls: sitemapRoutes.length, sitemapUrls: sitemapRoutes.length,
hreflangLinks, 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 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 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 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)); console.log(JSON.stringify(result, null, 2));
if (!result.passed) process.exitCode = 1; 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 path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { verifyArtifact } from './phase10-artifact-verify.mjs'; 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') { async function fixture(mode = 'staging', forms = 'live') {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-phase10-artifact-')); 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>'); await fs.appendFile(path.join(root, 'index.html'), '<script type="application/json" data-portal-payload>{"streams":[]}</script>');
const result = await verifyArtifact(root, 'staging'); const result = await verifyArtifact(root, 'staging');
assert.equal(result.passed, false); 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'))); 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 () => { test('artifact rejects extra routes, wrong origins and prematurely loaded drawers', async () => {
const root = await fixture(); 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 }); } } 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 { readPortalInput } = await import('../src/utils/portal-input.mjs');
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-portal-input-')); const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-portal-input-'));
try { try {
const pass = randomBytes(24).toString('base64'); const pass = randomBytes(24).toString('base64');
const streams = await Promise.all(['one','two'].map((id) => encryptPortalStream(id, '<p>Synthetic</p>', pass))); 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 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)); assert.ok(readPortalInput(options));
// Form modes cannot substitute for the independent private-input approval. // Form modes cannot substitute for the independent private-input approval.
for (const mode of ['live', 'off', 'mock']) { for (const mode of ['live', 'off', 'mock']) {
assert.ok(readPortalInput({ ...options, mode })); 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); for (const changed of [{ localApproved: undefined }, { localApproved: 'false' }]) assert.equal(readPortalInput({ ...options, ...changed }), undefined);
await fs.writeFile(file, '<h1>Not encrypted</h1>'); assert.equal(readPortalInput(options), 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 }); } } finally { await fs.rm(dir, { recursive: true }); }
}); });