From f2815ba53e129508ebccae6f70745c186cc6d0e9 Mon Sep 17 00:00:00 2001 From: axiomlogicnexus Date: Mon, 22 Jun 2026 07:34:35 +0000 Subject: [PATCH] Add scripted VPS website staging proof --- ...LING_AND_DISTRIBUTION_PACKET_2026-06-22.md | 6 + ...ME_ORIGIN_DEPLOYMENT_HANDOFF_2026-06-22.md | 38 +- ...TE_VPS_PRECUTOVER_HOST_STATE_2026-06-22.md | 12 + .../HyperTwist/FEATURE_REGISTRY.md | 2 +- .../HyperTwist/ROADMAP.md | 4 +- website/README.md | 3 + website/deploy/README.md | 7 + website/package.json | 3 +- ...vps-same-origin-staging-proof-cli.test.mjs | 72 ++++ .../run-vps-same-origin-staging-proof-lib.mjs | 390 ++++++++++++++++++ ...vps-same-origin-staging-proof-lib.test.mjs | 140 +++++++ .../run-vps-same-origin-staging-proof.mjs | 160 +++++++ website/server/README.md | 4 + 13 files changed, 836 insertions(+), 5 deletions(-) create mode 100644 website/scripts/run-vps-same-origin-staging-proof-cli.test.mjs create mode 100644 website/scripts/run-vps-same-origin-staging-proof-lib.mjs create mode 100644 website/scripts/run-vps-same-origin-staging-proof-lib.test.mjs create mode 100644 website/scripts/run-vps-same-origin-staging-proof.mjs diff --git a/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_AUTH_BILLING_AND_DISTRIBUTION_PACKET_2026-06-22.md b/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_AUTH_BILLING_AND_DISTRIBUTION_PACKET_2026-06-22.md index e5d059c..8525d12 100644 --- a/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_AUTH_BILLING_AND_DISTRIBUTION_PACKET_2026-06-22.md +++ b/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_AUTH_BILLING_AND_DISTRIBUTION_PACKET_2026-06-22.md @@ -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: diff --git a/docs/ops/HYPERTWIST_WEBSITE_SAME_ORIGIN_DEPLOYMENT_HANDOFF_2026-06-22.md b/docs/ops/HYPERTWIST_WEBSITE_SAME_ORIGIN_DEPLOYMENT_HANDOFF_2026-06-22.md index 9818d74..10f8df0 100644 --- a/docs/ops/HYPERTWIST_WEBSITE_SAME_ORIGIN_DEPLOYMENT_HANDOFF_2026-06-22.md +++ b/docs/ops/HYPERTWIST_WEBSITE_SAME_ORIGIN_DEPLOYMENT_HANDOFF_2026-06-22.md @@ -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 diff --git a/docs/ops/HYPERTWIST_WEBSITE_VPS_PRECUTOVER_HOST_STATE_2026-06-22.md b/docs/ops/HYPERTWIST_WEBSITE_VPS_PRECUTOVER_HOST_STATE_2026-06-22.md index 5720f0e..4bebb58 100644 --- a/docs/ops/HYPERTWIST_WEBSITE_VPS_PRECUTOVER_HOST_STATE_2026-06-22.md +++ b/docs/ops/HYPERTWIST_WEBSITE_VPS_PRECUTOVER_HOST_STATE_2026-06-22.md @@ -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` diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md index 7e76fe1..140dd15 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md @@ -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. | diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md index 7b6b2ff..d2a187c 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md @@ -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 diff --git a/website/README.md b/website/README.md index 9a00ed1..7ba3c87 100644 --- a/website/README.md +++ b/website/README.md @@ -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 diff --git a/website/deploy/README.md b/website/deploy/README.md index 0e003f1..f46ccdf 100644 --- a/website/deploy/README.md +++ b/website/deploy/README.md @@ -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 --out-dir ` +- before any root-owned cutover, you can now also run + `npm run run:vps-same-origin-staging-proof -- --manifest --identity-file ` + 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` diff --git a/website/package.json b/website/package.json index 2aec7e2..a7110a9 100644 --- a/website/package.json +++ b/website/package.json @@ -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", diff --git a/website/scripts/run-vps-same-origin-staging-proof-cli.test.mjs b/website/scripts/run-vps-same-origin-staging-proof-cli.test.mjs new file mode 100644 index 0000000..d65fc91 --- /dev/null +++ b/website/scripts/run-vps-same-origin-staging-proof-cli.test.mjs @@ -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 }) + } + }) +}) diff --git a/website/scripts/run-vps-same-origin-staging-proof-lib.mjs b/website/scripts/run-vps-same-origin-staging-proof-lib.mjs new file mode 100644 index 0000000..c568960 --- /dev/null +++ b/website/scripts/run-vps-same-origin-staging-proof-lib.mjs @@ -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)) +} diff --git a/website/scripts/run-vps-same-origin-staging-proof-lib.test.mjs b/website/scripts/run-vps-same-origin-staging-proof-lib.test.mjs new file mode 100644 index 0000000..6468ab3 --- /dev/null +++ b/website/scripts/run-vps-same-origin-staging-proof-lib.test.mjs @@ -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', + ]) + }) +}) diff --git a/website/scripts/run-vps-same-origin-staging-proof.mjs b/website/scripts/run-vps-same-origin-staging-proof.mjs new file mode 100644 index 0000000..41860f7 --- /dev/null +++ b/website/scripts/run-vps-same-origin-staging-proof.mjs @@ -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() diff --git a/website/server/README.md b/website/server/README.md index 8ffb230..3ed8f0d 100644 --- a/website/server/README.md +++ b/website/server/README.md @@ -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: