Add scripted VPS website staging proof

This commit is contained in:
axiomlogicnexus 2026-06-22 07:34:35 +00:00
parent 3da1fabbcd
commit f2815ba53e
13 changed files with 836 additions and 5 deletions

View file

@ -373,6 +373,12 @@ the first-party website shell on the real shared host behind `3011`, so the
remaining work is root-owned cutover and production-value application rather
than repository-side website behavior.
The repo now also ships that host-proof path as a first-party helper under
`website/scripts/run-vps-same-origin-staging-proof.mjs`, allowing future
sessions to restage the current committed `website/` tree into a temporary VPS
checkout, prove the live `3011` behavior, and clean the temp lane back up
without rebuilding the SSH/archive/build/start sequence manually.
## Explicit non-claims
This packet does not claim any of the following:

View file

@ -134,6 +134,39 @@ cd /srv/hypertwist/current/website/server
npm ci
```
### 3A. Recommended non-root staging proof before cutover
From the local HyperTwist repo on the Linux side, run the first-party staging
proof helper against the real VPS before any root-owned vhost or service change:
```bash
cd /home/dev/src/HyperTwist/website
npm run run:vps-same-origin-staging-proof -- \
--manifest deploy/hypertwist.same-origin.bundle.json \
--identity-file /path/to/shared-vps-key \
--json
```
Current behavior of that helper:
- archives the current committed `website/` tree
- stages it into a temporary VPS checkout under `/tmp/`
- rewrites the manifest for temporary checkout ownership and port `3011`
- installs deps, renders the same-origin bundle, builds the frontend, and boots
the auth server on the shared-host-safe staging port
- verifies:
- `GET /health`
- `GET /api/auth/health`
- `GET /api/releases/manifest`
- root shell marker plus `#root` mount
- cleans the temporary checkout and staging listener back up on success
Latest proof result on `2026-06-22`:
- passed on the real shared VPS
- confirmed green health/auth/release/shell behavior on `3011`
- left no retained temp checkout afterward
### 4. Install the systemd service
Preferred validated bundle-generation path:
@ -236,8 +269,9 @@ public-host cutover has not yet been applied.
That remaining cutover gap is now narrower than before: an isolated VPS-local
staging proof on the same host succeeded on `3011` for `/health`,
`/api/auth/health`, `/api/releases/manifest`, and the first-party shell marker,
so the remaining work is root-owned enablement and vhost replacement rather
than unresolved repo-side runtime behavior.
and the new first-party staging-proof helper now reproduces that flow
programmatically, so the remaining work is root-owned enablement and vhost
replacement rather than unresolved repo-side runtime behavior.
### 7. Run the first-party readiness verifier

View file

@ -119,11 +119,23 @@ Operational reading:
- the repo-side same-origin website/auth-server lane is now proved on the real
shared VPS behind the new host-safe `3011` port
- the repo now also ships
`website/scripts/run-vps-same-origin-staging-proof.mjs` so that same
isolated-host proof can be rerun from the local HyperTwist repo without
hand-assembling the SSH/archive/build/boot sequence again
- the remaining gap is the root-owned live cutover from static
`/var/www/hypertwist` NGINX serving into the long-lived HyperTwist
website/auth-server process plus real production values, not another missing
repository-side deployment behavior
Follow-up proof on `2026-06-22`:
- the new scripted staging-proof helper reproduced that same `3011` host proof
successfully against the live VPS
- on success it removed the temporary staging checkout again
- on the final cleanup check, no `3011` listener remained and the temp checkout
path was absent
## Related authorities
- `docs/ops/HYPERTWIST_WEBSITE_SAME_ORIGIN_DEPLOYMENT_HANDOFF_2026-06-22.md`

View file

@ -263,7 +263,7 @@ repo.
| Feature | Status | Primary authority | Notes |
|---|---|---|---|
| Public `hypertwist.app` marketing shell | Implemented now | first-party `website/` app + feature registry/roadmap authority | HyperTwist now has a dedicated first-party public web surface for homepage, about, resources, pricing, download, support, and legal routes. This lane is separate from the embedded Unreal browser runtime under `Content/Browser/` and does not claim browser-simulator parity. The same package now also carries a first-party external runtime-readiness verifier so deploy-time env and live health posture can be checked outside the dashboard, plus separated local-versus-production env templates whose placeholder values are intentionally rejected until real launch config is in place, bootstrap CI now validates both the frontend and auth-server website commands directly, and the auth server can now auto-serve the built `website/dist` bundle with bounded SPA fallback for same-origin public deployment. Request-level server coverage now also proves that public/app shell delivery does not shadow `/api/*`, `/auth*`, `/health`, or missing asset paths, while the pricing/download/notices routes now surface first-party preview-versus-launch posture from the same bounded launch checklist instead of relying on hidden operator-only status. The live website lane now also owns route-aware title/description/canonical/Open-Graph/Twitter metadata for the real `hypertwist.app` marketing surface so deployed public pages no longer remain on a single generic SPA title/description, plus first-party `robots.txt` and `sitemap.xml` assets for the public route set while keeping `/app`, `/login`, and `/register` out of crawler posture. The real `check-runtime-readiness` CLI is now also exercised against the checked-in production example env files, and a spawned `website/server` bootstrap proof now verifies the live same-origin process path from production-shaped env into `/health`, `/api/auth/health`, built-shell serving, and the public anonymous release-manifest posture for the shared desktop release lane. The same verifier now also probes the deployed root-shell marker and can explicitly fail when the public origin is still serving the older placeholder rollout page instead of the first-party website/auth-server lane, while the repo now also carries first-party `website/deploy/` `nginx` plus `systemd` handoff templates, a concrete same-origin public-host cutover guide, a deployment-file renderer that emits resolved operator outputs from real checkout paths, and a manifest-driven bundle renderer that lets one authoritative input own the public origin while emitting validated env plus install artifacts together, with the shared-VPS-safe default upstream moved to `3011` after live host inspection confirmed `3001` is already occupied by FamiliarOS. An isolated VPS-local staging proof then also confirmed that the committed HyperTwist website lane can serve green health, release-manifest, and first-party shell responses on that real shared host behind `3011`, so the remaining gap is root-owned live cutover rather than missing repo-side website behavior. |
| Public `hypertwist.app` marketing shell | Implemented now | first-party `website/` app + feature registry/roadmap authority | HyperTwist now has a dedicated first-party public web surface for homepage, about, resources, pricing, download, support, and legal routes. This lane is separate from the embedded Unreal browser runtime under `Content/Browser/` and does not claim browser-simulator parity. The same package now also carries a first-party external runtime-readiness verifier so deploy-time env and live health posture can be checked outside the dashboard, plus separated local-versus-production env templates whose placeholder values are intentionally rejected until real launch config is in place, bootstrap CI now validates both the frontend and auth-server website commands directly, and the auth server can now auto-serve the built `website/dist` bundle with bounded SPA fallback for same-origin public deployment. Request-level server coverage now also proves that public/app shell delivery does not shadow `/api/*`, `/auth*`, `/health`, or missing asset paths, while the pricing/download/notices routes now surface first-party preview-versus-launch posture from the same bounded launch checklist instead of relying on hidden operator-only status. The live website lane now also owns route-aware title/description/canonical/Open-Graph/Twitter metadata for the real `hypertwist.app` marketing surface so deployed public pages no longer remain on a single generic SPA title/description, plus first-party `robots.txt` and `sitemap.xml` assets for the public route set while keeping `/app`, `/login`, and `/register` out of crawler posture. The real `check-runtime-readiness` CLI is now also exercised against the checked-in production example env files, and a spawned `website/server` bootstrap proof now verifies the live same-origin process path from production-shaped env into `/health`, `/api/auth/health`, built-shell serving, and the public anonymous release-manifest posture for the shared desktop release lane. The same verifier now also probes the deployed root-shell marker and can explicitly fail when the public origin is still serving the older placeholder rollout page instead of the first-party website/auth-server lane, while the repo now also carries first-party `website/deploy/` `nginx` plus `systemd` handoff templates, a concrete same-origin public-host cutover guide, a deployment-file renderer that emits resolved operator outputs from real checkout paths, and a manifest-driven bundle renderer that lets one authoritative input own the public origin while emitting validated env plus install artifacts together, with the shared-VPS-safe default upstream moved to `3011` after live host inspection confirmed `3001` is already occupied by FamiliarOS. An isolated VPS-local staging proof then also confirmed that the committed HyperTwist website lane can serve green health, release-manifest, and first-party shell responses on that real shared host behind `3011`, and the repo now also ships that host-proof flow as a first-party staging helper so future sessions can rerun the temp checkout/build/boot verification path directly before root-owned cutover. |
| Browser-based operator/account dashboard | Implemented now | first-party `website/` app + shared auth/dashboard packet | A protected browser dashboard is now live for operator access, account state, download posture, browser-access boundary explanation, notices review, and bounded billing/entitlement status. It reuses the shared SuperTokens auth posture proven in FamiliarOS and ScriptoriumAI while remaining HyperTwist-specific in product content and boundary claims, the current auth-health surface now truthfully distinguishes configured versus reachable or ready shared-core posture while exposing fallback-active reason instead of hardcoding readiness, and the same dashboard now also surfaces launch-readiness truth for download URLs, checkout links, source/notices URLs, billing-secret/map configuration, and local-versus-public runtime deployment posture. Focused frontend coverage now also protects deep-link login redirect preservation, safe `next`-path normalization across auth entry points, fallback/email auth-bootstrap normalization, login/register continuation behavior, public download-gating behavior, protected-route/shell behavior, real lazy-route tree behavior for key public and protected paths, top-level app-bootstrap and SuperTokens-wrapper posture, login/register unhappy-path and OAuth-button behavior, support-topic fallback routing when live checkout is not configured, desktop-link verify-url/dashboard readiness behavior, and explicit `noindex,nofollow` posture on protected/auth browser surfaces. The validation lane now also has a bounded signed test-session harness under `TEST_MODE=testing` that proves `/api/auth/me` and `/api/auth/desktop-link` behavior through the live spawned auth-server process without widening production auth posture. |
| Desktop download posture and browser-to-desktop pairing | Implemented now | first-party `website/` app + `website/server` desktop-link endpoints | Public download targets, dashboard-side release posture, and short-lived desktop-link token generation/verification are now first-party owned. The current server posture now enforces exact website-origin matching, bounded per-user issuance, one-time token consumption, and billing-backed plan/download entitlement resolution with focused `website/server` tests green on `2026-06-22`, and the verify handshake now returns the same resolved download-entitlement posture the dashboard sees instead of only identity plus plan/role. The same lane now also owns a shared `GET /api/releases/manifest` runtime authority for release version/channel/build/published/file-size/checksum/docs/source metadata, with anonymous callers intentionally denied raw download URLs while entitled session-backed callers receive the configured direct platform URL. The public `/download` page now keeps raw download URLs behind the protected dashboard instead of exposing them directly, preserves requested platform continuity through `/app/downloads?platform=...`, and surfaces that requested target again after auth handoff inside the protected release lane. Both the public and protected download surfaces now also carry first-party rollout steps plus release/notices/source references so the desktop setup lane is more than a generic link bucket, and the dashboard plus public launch-status callouts now consume the same manifest-backed Windows download truth instead of only static frontend config. Actual release URLs remain deployment configuration rather than hardcoded product truth. |
| Paddle-ready pricing and billing webhook seam | Implemented now | first-party `website/` app + `website/server` billing endpoint | The public pricing surface now exists with plan structure, checkout-link configuration seams, and the same `/api/billing/paddle/webhook` endpoint family used by the broader product website lane. The current server now verifies `Paddle-Signature` against `PADDLE_WEBHOOK_SECRET` using the documented raw-body HMAC flow, persists a bounded first-party billing state file, and applies verified Paddle events into account/download entitlement state that the browser dashboard consumes, with focused `website/server` tests green on `2026-06-22`. A spawned live-process proof now also verifies that a real signed webhook updates processed-event health and persisted billing state through the actual auth-server runtime, not only helper-level store tests, and transaction events no longer leak their id into stored `subscriptionId` state. Production checkout URLs, secret management, and broader operator/admin billing workflows remain deployment/application tasks, not shipped-code omissions. |

View file

@ -274,7 +274,9 @@ Current consolidated milestone snapshot:
FamiliarOS already occupies `3001`, and an isolated VPS-local staging proof
then confirmed the current committed HyperTwist `website/` lane can serve
green health, release-manifest, and first-party shell responses on that real
shared host behind `3011`,
shared host behind `3011`, with that same non-root host-proof flow now
codified in a first-party staging-proof helper so future sessions can rerun
the temp checkout/build/boot verification path directly,
and the live public website lane now also owns route-aware
title/description/canonical/Open-Graph/Twitter metadata for the real
`hypertwist.app` marketing surface while marking protected/auth routes as

View file

@ -142,6 +142,9 @@ Use the runtime-readiness command before public launch or deployment approval:
env, server env, `systemd`, and `nginx` outputs together before VPS cutover
- the shared-VPS deployment defaults now use upstream port `3011` because the
current host already has FamiliarOS bound to `3001`
- the repo now also includes `npm run run:vps-same-origin-staging-proof` so the
current committed `website/` tree can be staged into a temporary VPS checkout
and proven on the real shared host before any root-owned live cutover
- it now warns when same-origin public deployment leaves static website serving mode ambiguous
- request-level server tests now also pin that same-origin shell behavior instead of relying only on helper-level assertions
- the public pricing/download/notices pages now also surface preview-versus-launch posture directly from the same bounded launch checklist

View file

@ -10,6 +10,7 @@ Files:
- `systemd/hypertwist-website-auth-server.service.example`
- `../scripts/render-same-origin-deployment.mjs`
- `../scripts/render-same-origin-bundle.mjs`
- `../scripts/run-vps-same-origin-staging-proof.mjs`
Expected deployment posture:
@ -26,6 +27,12 @@ Before using these templates:
- preferred path: copy `hypertwist.same-origin.bundle.example.json`, replace the
placeholder values, and render a validated deployment bundle with
`npm run render:same-origin-bundle -- --manifest <file> --out-dir <dir>`
- before any root-owned cutover, you can now also run
`npm run run:vps-same-origin-staging-proof -- --manifest <file> --identity-file <key>`
from the local repo to stage the current committed `website/` tree into a VPS
temp checkout, boot it on the host-safe staging port, verify the real
`/health`, `/api/auth/health`, `/api/releases/manifest`, and root shell
surfaces, and clean the temp lane back up on success
- set `publicOrigin` to the exact live origin that should own both the website
and auth cookies; for the canonical production lane that value is
`https://hypertwist.app`

View file

@ -12,7 +12,8 @@
"test": "vitest run",
"check:runtime-readiness": "node scripts/check-runtime-readiness.mjs",
"render:same-origin-deployment": "node scripts/render-same-origin-deployment.mjs",
"render:same-origin-bundle": "node scripts/render-same-origin-bundle.mjs"
"render:same-origin-bundle": "node scripts/render-same-origin-bundle.mjs",
"run:vps-same-origin-staging-proof": "node scripts/run-vps-same-origin-staging-proof.mjs"
},
"dependencies": {
"@tanstack/react-query": "^5.83.0",

View file

@ -0,0 +1,72 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const currentFile = fileURLToPath(import.meta.url)
const websiteRoot = path.resolve(path.dirname(currentFile), '..')
const scriptPath = path.join(websiteRoot, 'scripts', 'run-vps-same-origin-staging-proof.mjs')
describe('run-vps-same-origin-staging-proof CLI', () => {
it('supports dry-run json output with a sanitized manifest preview', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hypertwist-staging-proof-cli-'))
const manifestPath = path.join(tempDir, 'bundle.json')
const identityFile = path.join(tempDir, 'id_ed25519')
fs.writeFileSync(manifestPath, JSON.stringify({
checkoutRoot: '/srv/hypertwist/current',
publicOrigin: 'https://hypertwist.app',
supportEmail: 'hello@hypertwist.app',
publicDocsUrl: 'https://hypertwist.app/docs',
releaseNotesUrl: 'https://hypertwist.app/changelog',
correspondingSourceUrl: 'https://git.scriptoriumai.io/scriptoriumadmin/hypertwist',
openSourceRepoUrl: 'https://git.scriptoriumai.io/scriptoriumadmin/hypertwist',
operatorCheckoutUrl: 'https://buy.paddle.com/operator',
studioCheckoutUrl: 'https://buy.paddle.com/studio',
windowsDownloadUrl: 'https://downloads.hypertwist.app/windows.exe',
releaseManifestVersion: '1.0.0',
windowsRelease: {
buildId: 'win64-1000',
sha256: 'abc123',
publishedAt: '2026-06-22T00:00:00.000Z',
},
server: {
paddleWebhookSecret: 'super-secret-token',
},
}, null, 2))
fs.writeFileSync(identityFile, 'fake-key', 'utf8')
try {
const result = spawnSync(
process.execPath,
[
scriptPath,
'--manifest',
manifestPath,
'--identity-file',
identityFile,
'--dry-run',
'--json',
],
{
cwd: websiteRoot,
encoding: 'utf8',
},
)
expect(result.status).toBe(0)
expect(result.stderr).toBe('')
const payload = JSON.parse(result.stdout)
expect(payload.vpsHost).toBe('212.227.13.220')
expect(payload.port).toBe(3011)
expect(payload.remoteManifest.server.paddleWebhookSecret).toBe('su***en')
expect(payload.remoteScript).toContain('__HYPERTWIST_STAGING_SUMMARY__')
} finally {
fs.rmSync(tempDir, { recursive: true, force: true })
}
})
})

View file

@ -0,0 +1,390 @@
import fs from 'node:fs'
import path from 'node:path'
function normalizeTrimmed(value) {
return String(value || '').trim()
}
function normalizeBoolean(value, fallback = false) {
if (typeof value === 'boolean') {
return value
}
const normalized = normalizeTrimmed(value).toLowerCase()
if (!normalized) {
return fallback
}
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'
}
function requireNonEmpty(value, label) {
const normalized = normalizeTrimmed(value)
if (!normalized) {
throw new Error(`${label} is required.`)
}
return normalized
}
function requirePositiveInteger(value, label) {
const parsed = Number.parseInt(String(value || ''), 10)
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${label} must be a positive integer.`)
}
return parsed
}
function maskSecretValue(value) {
const normalized = String(value ?? '')
if (!normalized) {
return ''
}
if (normalized.length <= 4) {
return '****'
}
return `${normalized.slice(0, 2)}***${normalized.slice(-2)}`
}
function maskManifestSecrets(value, key = '') {
if (Array.isArray(value)) {
return value.map((item) => maskManifestSecrets(item))
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([entryKey, entryValue]) => [entryKey, maskManifestSecrets(entryValue, entryKey)]),
)
}
if (typeof value === 'string' && /secret/i.test(key)) {
return maskSecretValue(value)
}
return value
}
export function parseStagingProofArgs(argv) {
const options = {
manifest: '',
identityFile: '',
vpsHost: '212.227.13.220',
vpsUser: 'dev',
remoteDir: '',
port: '3011',
waitSeconds: '20',
keepRemoteDir: false,
json: false,
dryRun: false,
}
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
const next = argv[index + 1] || ''
switch (argument) {
case '--manifest':
options.manifest = next
index += 1
break
case '--identity-file':
options.identityFile = next
index += 1
break
case '--vps-host':
options.vpsHost = next
index += 1
break
case '--vps-user':
options.vpsUser = next
index += 1
break
case '--remote-dir':
options.remoteDir = next
index += 1
break
case '--port':
options.port = next
index += 1
break
case '--wait-seconds':
options.waitSeconds = next
index += 1
break
case '--keep-remote-dir':
options.keepRemoteDir = true
break
case '--json':
options.json = true
break
case '--dry-run':
options.dryRun = true
break
default:
break
}
}
return options
}
export function loadStagingProofManifest(filePath) {
const absolutePath = path.resolve(filePath)
const raw = fs.readFileSync(absolutePath, 'utf8')
return {
path: absolutePath,
manifest: JSON.parse(raw),
}
}
export function buildDefaultRemoteDir(gitRef = 'head') {
const normalized = normalizeTrimmed(gitRef).replace(/[^a-zA-Z0-9._-]/g, '-')
return `/tmp/hypertwist-website-staging-${normalized || 'head'}`
}
export function resolveStagingProofOptions({
manifestPath,
identityFile,
vpsHost,
vpsUser,
remoteDir,
port,
waitSeconds,
keepRemoteDir,
json,
dryRun,
gitRef,
}) {
return {
manifestPath: path.resolve(requireNonEmpty(manifestPath, 'manifestPath')),
identityFile: path.resolve(requireNonEmpty(identityFile, 'identityFile')),
vpsHost: requireNonEmpty(vpsHost || '212.227.13.220', 'vpsHost'),
vpsUser: requireNonEmpty(vpsUser || 'dev', 'vpsUser'),
remoteDir: normalizeTrimmed(remoteDir) || buildDefaultRemoteDir(gitRef),
port: requirePositiveInteger(port || '3011', 'port'),
waitSeconds: requirePositiveInteger(waitSeconds || '20', 'waitSeconds'),
keepRemoteDir: normalizeBoolean(keepRemoteDir, false),
json: normalizeBoolean(json, false),
dryRun: normalizeBoolean(dryRun, false),
}
}
export function buildRemoteStagingManifest(manifest, {
remoteDir,
port,
}) {
const server = manifest?.server && typeof manifest.server === 'object'
? manifest.server
: {}
return {
...manifest,
checkoutRoot: remoteDir,
server: {
...server,
port: String(port),
serveStaticWebsite: true,
websiteDistPath: `${remoteDir}/website/dist`,
billingStatePath: `${remoteDir}/hypertwist-billing-state.json`,
},
}
}
export function sanitizeStagingProofManifest(manifest) {
return maskManifestSecrets(manifest)
}
export function buildRemoteStagingProofScript({
remoteDir,
waitSeconds,
keepRemoteDir,
manifest,
}) {
const manifestJson = JSON.stringify(manifest, null, 2)
const port = String(manifest.server?.port || '')
return `set -euo pipefail
REMOTE_DIR=${JSON.stringify(remoteDir)}
WAIT_SECONDS=${Number(waitSeconds)}
KEEP_REMOTE_DIR=${keepRemoteDir ? '1' : '0'}
PORT=${JSON.stringify(port)}
WEBSITE_DIR="$REMOTE_DIR/website"
MANIFEST_PATH="$REMOTE_DIR/bundle.json"
BUNDLE_OUT="$REMOTE_DIR/rendered"
LOG_DIR="$REMOTE_DIR/logs"
SERVER_PID=""
PROOF_OK=0
cleanup() {
LISTENER_PIDS=$(ss -ltnp 2>/dev/null | sed -n "s/.*:$PORT[^0-9].*pid=\\([0-9]\\+\\).*/\\1/p" | sort -u || true)
if [ -n "$LISTENER_PIDS" ]; then
for listener_pid in $LISTENER_PIDS; do
kill "$listener_pid" 2>/dev/null || true
done
fi
if [ -n "\${SERVER_PID:-}" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
if [ "$KEEP_REMOTE_DIR" = "1" ] || [ "$PROOF_OK" != "1" ]; then
return
fi
rm -rf "$REMOTE_DIR"
}
trap cleanup EXIT
mkdir -p "$REMOTE_DIR" "$LOG_DIR"
cd "$WEBSITE_DIR"
printf "[remote_dir] %s\\n" "$REMOTE_DIR"
printf "[proof_port] %s\\n" "$PORT"
cat > "$MANIFEST_PATH" <<'__HYPERTWIST_STAGING_MANIFEST__'
${manifestJson}
__HYPERTWIST_STAGING_MANIFEST__
npm ci > "$LOG_DIR/website-npm-ci.log" 2>&1
npm --prefix server ci > "$LOG_DIR/server-npm-ci.log" 2>&1
npm run render:same-origin-bundle -- --manifest "$MANIFEST_PATH" --out-dir "$BUNDLE_OUT" --summary > "$LOG_DIR/bundle-summary.log" 2>&1
cp "$BUNDLE_OUT/website.env" .env
cp "$BUNDLE_OUT/server.env" server/.env
npm run build > "$LOG_DIR/build.log" 2>&1
systemd-analyze verify "$BUNDLE_OUT/hypertwist-website-auth-server.service" > "$LOG_DIR/systemd-verify.log" 2>&1
npm --prefix server start > "$LOG_DIR/server.log" 2>&1 &
SERVER_PID=$!
for attempt in $(seq 1 "$WAIT_SECONDS"); do
if curl --silent --show-error "http://127.0.0.1:$PORT/health" > "$LOG_DIR/health.json" 2> "$LOG_DIR/health.stderr"; then
break
fi
sleep 1
done
if [ ! -s "$LOG_DIR/health.json" ]; then
printf "Health endpoint never became ready on port %s.\\n" "$PORT" >&2
sed -n '1,200p' "$LOG_DIR/server.log" >&2 || true
exit 1
fi
curl --silent --show-error "http://127.0.0.1:$PORT/api/auth/health" > "$LOG_DIR/auth-health.json"
curl --silent --show-error "http://127.0.0.1:$PORT/api/releases/manifest" > "$LOG_DIR/release-manifest.json"
curl --silent --show-error "http://127.0.0.1:$PORT/" > "$LOG_DIR/root.html"
ss -ltnp | rg ":$PORT" > "$LOG_DIR/listener.txt" || true
node - "$REMOTE_DIR" <<'__HYPERTWIST_STAGING_SUMMARY__'
const fs = require('node:fs')
const path = require('node:path')
const remoteDir = process.argv[2]
const logDir = path.join(remoteDir, 'logs')
function readJson(fileName) {
return JSON.parse(fs.readFileSync(path.join(logDir, fileName), 'utf8'))
}
function readText(fileName) {
return fs.readFileSync(path.join(logDir, fileName), 'utf8')
}
const health = readJson('health.json')
const authHealth = readJson('auth-health.json')
const releaseManifest = readJson('release-manifest.json')
const rootHtml = readText('root.html')
const listenerOutput = fs.existsSync(path.join(logDir, 'listener.txt'))
? readText('listener.txt')
: ''
const markerPresent = rootHtml.includes('hypertwist-site-shell')
const rootMountPresent = rootHtml.includes('id="root"')
const releasePlatforms = Array.isArray(releaseManifest?.manifest?.platforms)
? releaseManifest.manifest.platforms.map((platform) => ({
platform_key: platform.platform_key,
configured: Boolean(platform.configured),
download_available: Boolean(platform.download_available),
}))
: []
const failures = []
if (!health?.ok) failures.push('/health did not return ok=true.')
if (!authHealth?.ok) failures.push('/api/auth/health did not return ok=true.')
if (!authHealth?.supertokens?.ready) failures.push('SuperTokens did not report ready=true.')
if (!authHealth?.runtime?.public_origin_ready) failures.push('Runtime did not report public_origin_ready=true.')
if (!releaseManifest?.ok) failures.push('/api/releases/manifest did not return ok=true.')
if (!markerPresent) failures.push('Root shell marker was not present in /.')
if (!rootMountPresent) failures.push('Root app mount was not present in /.')
if (!listenerOutput.trim()) failures.push('Expected listener on the staging port was not visible in ss output.')
const summary = {
ok: failures.length === 0,
remoteDir,
health,
authHealth: {
ok: Boolean(authHealth?.ok),
supertokens: authHealth?.supertokens || null,
fallback: authHealth?.fallback || null,
billing: authHealth?.billing || null,
runtime: authHealth?.runtime || null,
},
releaseManifest: {
ok: Boolean(releaseManifest?.ok),
support_email: releaseManifest?.manifest?.support_email || null,
viewer: releaseManifest?.manifest?.viewer || null,
platforms: releasePlatforms,
},
rootShell: {
markerPresent,
rootMountPresent,
},
listenerOutput: listenerOutput.trim(),
failures,
}
process.stdout.write(\`__HYPERTWIST_STAGING_SUMMARY__\${JSON.stringify(summary)}\\n\`)
if (!summary.ok) {
process.exit(1)
}
__HYPERTWIST_STAGING_SUMMARY__
PROOF_OK=1
printf "[proof_result] ok\\n"
`
}
export function buildStageArchiveRemoteCommand(remoteDir) {
return `rm -rf ${JSON.stringify(remoteDir)} && mkdir -p ${JSON.stringify(remoteDir)} && tar -xf - -C ${JSON.stringify(remoteDir)}`
}
export function buildSshArgs({
identityFile,
vpsUser,
vpsHost,
remoteCommand,
}) {
const args = [
'-i',
identityFile,
'-o',
'StrictHostKeyChecking=no',
`${vpsUser}@${vpsHost}`,
]
if (remoteCommand) {
args.push(remoteCommand)
}
return args
}
export function extractStagingProofSummary(output) {
const marker = '__HYPERTWIST_STAGING_SUMMARY__'
const lines = String(output || '').split('\n')
const summaryLine = [...lines].reverse().find((line) => line.startsWith(marker))
if (!summaryLine) {
return null
}
return JSON.parse(summaryLine.slice(marker.length))
}

View file

@ -0,0 +1,140 @@
import { describe, expect, it } from 'vitest'
import {
buildDefaultRemoteDir,
buildRemoteStagingManifest,
buildRemoteStagingProofScript,
buildSshArgs,
buildStageArchiveRemoteCommand,
extractStagingProofSummary,
resolveStagingProofOptions,
sanitizeStagingProofManifest,
} from './run-vps-same-origin-staging-proof-lib.mjs'
function createManifest() {
return {
checkoutRoot: '/srv/hypertwist/current',
publicOrigin: 'https://hypertwist.app',
supportEmail: 'hello@hypertwist.app',
publicDocsUrl: 'https://hypertwist.app/docs',
releaseNotesUrl: 'https://hypertwist.app/changelog',
correspondingSourceUrl: 'https://git.scriptoriumai.io/scriptoriumadmin/hypertwist',
openSourceRepoUrl: 'https://git.scriptoriumai.io/scriptoriumadmin/hypertwist',
operatorCheckoutUrl: 'https://buy.paddle.com/operator',
studioCheckoutUrl: 'https://buy.paddle.com/studio',
windowsDownloadUrl: 'https://downloads.hypertwist.app/windows.exe',
releaseManifestVersion: '1.0.0',
windowsRelease: {
buildId: 'win64-1000',
sha256: 'abc123',
publishedAt: '2026-06-22T00:00:00.000Z',
},
server: {
port: '3011',
websiteDistPath: '/srv/hypertwist/current/website/dist',
billingStatePath: '/var/lib/hypertwist/auth/hypertwist-billing-state.json',
paddleWebhookSecret: 'super-secret-token',
},
}
}
describe('resolveStagingProofOptions', () => {
it('normalizes the required CLI inputs', () => {
const options = resolveStagingProofOptions({
manifestPath: 'deploy/hypertwist.same-origin.bundle.json',
identityFile: '/tmp/key',
vpsHost: '212.227.13.220',
vpsUser: 'dev',
remoteDir: '',
port: '3011',
waitSeconds: '30',
keepRemoteDir: true,
json: true,
dryRun: false,
gitRef: 'abc1234',
})
expect(options.remoteDir).toBe('/tmp/hypertwist-website-staging-abc1234')
expect(options.port).toBe(3011)
expect(options.waitSeconds).toBe(30)
expect(options.keepRemoteDir).toBe(true)
expect(options.json).toBe(true)
})
})
describe('buildRemoteStagingManifest', () => {
it('rewrites runtime-owned paths for the remote staging checkout', () => {
const manifest = buildRemoteStagingManifest(createManifest(), {
remoteDir: '/tmp/hypertwist-stage',
port: 3011,
})
expect(manifest.checkoutRoot).toBe('/tmp/hypertwist-stage')
expect(manifest.server.port).toBe('3011')
expect(manifest.server.websiteDistPath).toBe('/tmp/hypertwist-stage/website/dist')
expect(manifest.server.billingStatePath).toBe('/tmp/hypertwist-stage/hypertwist-billing-state.json')
expect(manifest.server.serveStaticWebsite).toBe(true)
})
})
describe('sanitizeStagingProofManifest', () => {
it('masks secret-like values before dry-run output', () => {
const sanitized = sanitizeStagingProofManifest(createManifest())
expect(sanitized.server.paddleWebhookSecret).toBe('su***en')
})
})
describe('buildRemoteStagingProofScript', () => {
it('includes the expected remote proof steps and summary marker', () => {
const script = buildRemoteStagingProofScript({
remoteDir: '/tmp/hypertwist-stage',
waitSeconds: 20,
keepRemoteDir: false,
manifest: buildRemoteStagingManifest(createManifest(), {
remoteDir: '/tmp/hypertwist-stage',
port: 3011,
}),
})
expect(script).toContain('npm run render:same-origin-bundle')
expect(script).toContain('systemd-analyze verify "$BUNDLE_OUT/hypertwist-website-auth-server.service"')
expect(script).toContain('http://127.0.0.1:$PORT/api/auth/health')
expect(script).toContain('LISTENER_PIDS=$(ss -ltnp')
expect(script).toContain('__HYPERTWIST_STAGING_SUMMARY__')
})
})
describe('extractStagingProofSummary', () => {
it('parses the emitted summary marker from remote output', () => {
const summary = extractStagingProofSummary([
'[remote_dir] /tmp/hypertwist-stage',
'__HYPERTWIST_STAGING_SUMMARY__{"ok":true,"remoteDir":"/tmp/hypertwist-stage"}',
].join('\n'))
expect(summary).toEqual({
ok: true,
remoteDir: '/tmp/hypertwist-stage',
})
})
})
describe('ssh helpers', () => {
it('builds the archive and ssh command shape', () => {
expect(buildDefaultRemoteDir('abc1234')).toBe('/tmp/hypertwist-website-staging-abc1234')
expect(buildStageArchiveRemoteCommand('/tmp/hypertwist-stage')).toContain('tar -xf - -C "/tmp/hypertwist-stage"')
expect(buildSshArgs({
identityFile: '/tmp/key',
vpsUser: 'dev',
vpsHost: '212.227.13.220',
remoteCommand: 'echo hello',
})).toEqual([
'-i',
'/tmp/key',
'-o',
'StrictHostKeyChecking=no',
'dev@212.227.13.220',
'echo hello',
])
})
})

View file

@ -0,0 +1,160 @@
#!/usr/bin/env node
import { execFileSync, spawnSync } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
buildRemoteStagingManifest,
buildRemoteStagingProofScript,
buildSshArgs,
buildStageArchiveRemoteCommand,
extractStagingProofSummary,
loadStagingProofManifest,
parseStagingProofArgs,
resolveStagingProofOptions,
sanitizeStagingProofManifest,
} from './run-vps-same-origin-staging-proof-lib.mjs'
const currentFile = fileURLToPath(import.meta.url)
const websiteRoot = path.resolve(path.dirname(currentFile), '..')
const repoRoot = path.resolve(websiteRoot, '..')
function runCommand(command, args, options = {}) {
const result = spawnSync(command, args, {
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
...options,
})
if (result.error) {
throw result.error
}
return result
}
function main() {
const rawArgs = parseStagingProofArgs(process.argv.slice(2))
const gitRef = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
cwd: repoRoot,
encoding: 'utf8',
}).trim()
const options = resolveStagingProofOptions({
manifestPath: rawArgs.manifest,
identityFile: rawArgs.identityFile,
vpsHost: rawArgs.vpsHost,
vpsUser: rawArgs.vpsUser,
remoteDir: rawArgs.remoteDir,
port: rawArgs.port,
waitSeconds: rawArgs.waitSeconds,
keepRemoteDir: rawArgs.keepRemoteDir,
json: rawArgs.json,
dryRun: rawArgs.dryRun,
gitRef,
})
const { path: manifestPath, manifest } = loadStagingProofManifest(options.manifestPath)
const remoteManifest = buildRemoteStagingManifest(manifest, {
remoteDir: options.remoteDir,
port: options.port,
})
const remoteScript = buildRemoteStagingProofScript({
remoteDir: options.remoteDir,
waitSeconds: options.waitSeconds,
keepRemoteDir: options.keepRemoteDir,
manifest: remoteManifest,
})
const archiveCommand = buildStageArchiveRemoteCommand(options.remoteDir)
const sanitizedManifest = sanitizeStagingProofManifest(remoteManifest)
if (options.dryRun) {
const payload = {
repoRoot,
websiteRoot,
manifestPath,
vpsHost: options.vpsHost,
vpsUser: options.vpsUser,
remoteDir: options.remoteDir,
port: options.port,
waitSeconds: options.waitSeconds,
keepRemoteDir: options.keepRemoteDir,
archiveCommand,
remoteManifest: sanitizedManifest,
remoteScript,
}
if (options.json) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`)
} else {
process.stdout.write(`[staging-proof] manifest: ${manifestPath}\n`)
process.stdout.write(`[staging-proof] vps: ${options.vpsUser}@${options.vpsHost}\n`)
process.stdout.write(`[staging-proof] remote dir: ${options.remoteDir}\n`)
process.stdout.write(`[staging-proof] port: ${options.port}\n`)
process.stdout.write(`[staging-proof] archive command: ${archiveCommand}\n`)
}
return
}
const archiveBuffer = execFileSync('git', ['archive', '--format=tar', 'HEAD', 'website'], {
cwd: repoRoot,
encoding: null,
maxBuffer: 64 * 1024 * 1024,
})
const stageResult = runCommand('ssh', buildSshArgs({
identityFile: options.identityFile,
vpsUser: options.vpsUser,
vpsHost: options.vpsHost,
remoteCommand: archiveCommand,
}), {
input: archiveBuffer,
encoding: 'utf8',
})
if (stageResult.status !== 0) {
process.stderr.write(stageResult.stderr || '')
process.exit(stageResult.status || 1)
}
const proofResult = runCommand('ssh', [
...buildSshArgs({
identityFile: options.identityFile,
vpsUser: options.vpsUser,
vpsHost: options.vpsHost,
}),
'bash',
'-s',
], {
input: remoteScript,
})
if (!options.json) {
process.stdout.write(proofResult.stdout || '')
}
process.stderr.write(proofResult.stderr || '')
const summary = extractStagingProofSummary(proofResult.stdout || '')
if (options.json) {
process.stdout.write(`${JSON.stringify({
ok: proofResult.status === 0,
manifestPath,
vpsHost: options.vpsHost,
vpsUser: options.vpsUser,
remoteDir: options.remoteDir,
port: options.port,
keepRemoteDir: options.keepRemoteDir,
summary,
}, null, 2)}\n`)
}
if (proofResult.status !== 0) {
process.exit(proofResult.status || 1)
}
}
main()

View file

@ -95,6 +95,10 @@ The server now also supports a bounded first-party same-origin deployment mode:
- the website package now also includes `npm run render:same-origin-bundle`
so one manifest file can own the public origin and emit validated frontend
env, server env, `systemd`, and `nginx` outputs together before installation
- the website package now also includes
`npm run run:vps-same-origin-staging-proof` so the same bundle manifest can
be exercised through a temporary VPS checkout on the real shared host before
root-owned service/vhost cutover
The website package now also ships a deploy-time verification command: