feat(deploy): add Contabo release workflow and portal payload gate
This commit is contained in:
1 parent
5fa54719b2
commit
ba93a54942
18 files changed
+697
-28
No files matched your search
+4
-1
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
---
|
||||
|
||||
@@ -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'); }
|
||||
}
|
||||
@@ -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 }); }
|
||||
});
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; });
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 }); }
|
||||
});
|
||||
Reference in new issue
Block a user