From 5d2e9755143fc10657ebe67cf43c9143ea201db8 Mon Sep 17 00:00:00 2001 From: axiomlogicnexus Date: Mon, 22 Jun 2026 08:07:39 +0000 Subject: [PATCH] Add scripted live website deploy helper --- ...LING_AND_DISTRIBUTION_PACKET_2026-06-22.md | 8 + ..._RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md | 4 + ...ME_ORIGIN_DEPLOYMENT_HANDOFF_2026-06-22.md | 32 ++ .../HyperTwist/FEATURE_REGISTRY.md | 2 +- .../HyperTwist/ROADMAP.md | 5 +- website/README.md | 6 + website/deploy/README.md | 5 + website/package.json | 3 +- ...n-vps-same-origin-live-deploy-cli.test.mjs | 74 +++++ .../run-vps-same-origin-live-deploy-lib.mjs | 299 ++++++++++++++++++ ...n-vps-same-origin-live-deploy-lib.test.mjs | 129 ++++++++ .../run-vps-same-origin-live-deploy.mjs | 293 +++++++++++++++++ website/server/README.md | 4 + 13 files changed, 861 insertions(+), 3 deletions(-) create mode 100644 website/scripts/run-vps-same-origin-live-deploy-cli.test.mjs create mode 100644 website/scripts/run-vps-same-origin-live-deploy-lib.mjs create mode 100644 website/scripts/run-vps-same-origin-live-deploy-lib.test.mjs create mode 100644 website/scripts/run-vps-same-origin-live-deploy.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 3527eee..5e42ee0 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 @@ -395,6 +395,14 @@ the shared VPS passed with `runtime.mode: mixed`, `public_origin_ready: true`, `webhookSecretConfigured: false`, and all release-manifest platforms still unconfigured for anonymous viewers. +The same package now also ships a bounded root-owned +`website/scripts/run-vps-same-origin-live-deploy.mjs` helper. That helper +stages the committed checkout, uploads the rendered bundle, installs env, +builds the website, replaces the live `systemd` plus `nginx` files, and then +validates the public origin through the same runtime-readiness lane. It was +re-proved idempotently against the already-live `hypertwist.app` preview +deployment on `2026-06-22`. + ## Explicit non-claims This packet does not claim any of the following: diff --git a/docs/ops/HYPERTWIST_WEBSITE_RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md b/docs/ops/HYPERTWIST_WEBSITE_RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md index cdb61df..f239e23 100644 --- a/docs/ops/HYPERTWIST_WEBSITE_RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md +++ b/docs/ops/HYPERTWIST_WEBSITE_RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md @@ -95,6 +95,10 @@ when a built website bundle exists: - the website package now also ships `npm run render:same-origin-bundle` so a single manifest can own the exact public origin and emit validated frontend env, server env, `systemd`, and `nginx` outputs before installation +- the website package now also ships `npm run run:vps-same-origin-live-deploy` + so the same manifest/bundle lane can execute the bounded root-owned + checkout/env/build/systemd/nginx replacement flow and then validate the live + public origin automatically ## Preview-tier rehearsal posture 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 4b89dee..43afb47 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 @@ -196,6 +196,38 @@ Live cutover result on `2026-06-22`: - the remaining gap is launch-tier value completion, not root-owned website host cutover +### 3B. Scripted live deploy after proof + +The repo now also carries a bounded root-owned deploy helper that codifies the +same checkout/env/build/systemd/nginx sequence that was used for the live +preview cutover: + +```bash +cd /home/dev/src/HyperTwist/website +npm run run:vps-same-origin-live-deploy -- \ + --manifest /tmp/hypertwist.preview.bundle.json \ + --identity-file /path/to/shared-vps-key \ + --json +``` + +Current behavior of that helper: + +- stages the committed `website/` tree into the live checkout root +- uploads the rendered bundle outputs +- creates the runtime user/group and owned state directories when needed +- installs `website/.env` plus `website/server/.env` +- runs `npm ci`, `npm --prefix server ci`, and `npm run build` as the service user +- verifies the rendered `systemd` unit, replaces the live `systemd` plus + `nginx` files, restarts the service, reloads NGINX, and then validates the + public origin through the runtime-readiness lane +- supports `--dry-run`, `--json`, `--archive-source worktree`, + `--skip-live-validation`, and `--keep-bundle-dir` + +Latest helper result on `2026-06-22`: + +- passed against the already-live shared-VPS preview deployment +- confirmed the helper is idempotent for the current preview-tier host state + ### 4. Install the systemd service Preferred validated bundle-generation path: 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 838116f..9de98ba 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. The same deployment lane now also distinguishes `launch` from `preview` posture so honest missing checkout/download/webhook/release values are accepted only for non-public rehearsal while placeholder strings still fail, `runtime.mode: mixed` plus `public_origin_ready: true` counts as valid preview-host proof, and the staging helper can archive either committed `HEAD` or the live worktree through `--archive-source worktree`. An isolated VPS-local staging proof then confirmed that both the committed HyperTwist website lane and the later preview-tier worktree packet can serve green health, release-manifest, and first-party shell responses on that real shared host behind `3011`, and a later root-owned cutover then replaced the public placeholder site with the live first-party same-origin preview deployment on `https://hypertwist.app`. 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 or after root-owned cutover. | +| 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. The same deployment lane now also distinguishes `launch` from `preview` posture so honest missing checkout/download/webhook/release values are accepted only for non-public rehearsal while placeholder strings still fail, `runtime.mode: mixed` plus `public_origin_ready: true` counts as valid preview-host proof, and the staging helper can archive either committed `HEAD` or the live worktree through `--archive-source worktree`. An isolated VPS-local staging proof then confirmed that both the committed HyperTwist website lane and the later preview-tier worktree packet can serve green health, release-manifest, and first-party shell responses on that real shared host behind `3011`, and a later root-owned cutover then replaced the public placeholder site with the live first-party same-origin preview deployment on `https://hypertwist.app`. The package now also ships a bounded root-owned live-deploy helper that stages the committed checkout, uploads the rendered bundle, installs env, rebuilds the site, replaces the live `systemd` plus `nginx` files, and validates the public origin; that helper has already been re-proved idempotently against the live host. 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 or after 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 a98ab99..11e7672 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md @@ -285,7 +285,10 @@ Current consolidated milestone snapshot: host before commit, and a later root-owned cutover then replaced the old `/var/www/hypertwist` placeholder site with the live first-party same-origin preview deployment on `https://hypertwist.app` while launch-tier commercial - and release values remain intentionally pending, + and release values remain intentionally pending, with that same root-owned + checkout/env/build/systemd/nginx replacement flow now codified in a + first-party live-deploy helper that has been re-proved idempotently on the + already-live host, 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 e8a48a6..05d3588 100644 --- a/website/README.md +++ b/website/README.md @@ -160,6 +160,10 @@ Use the runtime-readiness command before public launch or deployment approval: and proven on the real shared host before any root-owned live cutover, with optional `--archive-source worktree` support when the proof should validate the in-progress local packet before commit rather than `HEAD` +- the repo now also includes `npm run run:vps-same-origin-live-deploy` so the + same manifest/bundle lane can stage the committed checkout, install env, + build the site, replace the `systemd` plus `nginx` files, and validate the + public origin in one bounded root-owned flow - 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 @@ -171,6 +175,8 @@ Live site status on `2026-06-22`: instead of the older placeholder rollout page - live `/health`, `/api/auth/health`, `/api/releases/manifest`, and root-shell verification are green on that public origin +- the new live-deploy helper has also been re-proved idempotently against that + already-cut-over host The repo bootstrap CI now also validates this lane through: diff --git a/website/deploy/README.md b/website/deploy/README.md index 46ddfcf..5967691 100644 --- a/website/deploy/README.md +++ b/website/deploy/README.md @@ -40,6 +40,11 @@ Before using these templates: surfaces, and clean the temp lane back up on success; add `--archive-source worktree` when the proof should use the in-progress local worktree rather than committed `HEAD` +- after staging proof or for direct root-owned rollout, you can now also run + `npm run run:vps-same-origin-live-deploy -- --manifest --identity-file ` + to stage the committed checkout, upload the rendered bundle, install env, + build the site, replace the live `systemd` plus `nginx` files, and validate + the public origin through the same readiness lane - 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 a7110a9..66afdc2 100644 --- a/website/package.json +++ b/website/package.json @@ -13,7 +13,8 @@ "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", - "run:vps-same-origin-staging-proof": "node scripts/run-vps-same-origin-staging-proof.mjs" + "run:vps-same-origin-staging-proof": "node scripts/run-vps-same-origin-staging-proof.mjs", + "run:vps-same-origin-live-deploy": "node scripts/run-vps-same-origin-live-deploy.mjs" }, "dependencies": { "@tanstack/react-query": "^5.83.0", diff --git a/website/scripts/run-vps-same-origin-live-deploy-cli.test.mjs b/website/scripts/run-vps-same-origin-live-deploy-cli.test.mjs new file mode 100644 index 0000000..e4709cc --- /dev/null +++ b/website/scripts/run-vps-same-origin-live-deploy-cli.test.mjs @@ -0,0 +1,74 @@ +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-live-deploy.mjs') + +describe('run-vps-same-origin-live-deploy CLI', () => { + it('supports dry-run json output with a sanitized manifest preview', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hypertwist-live-deploy-cli-')) + const manifestPath = path.join(tempDir, 'bundle.json') + const identityFile = path.join(tempDir, 'id_ed25519') + + fs.writeFileSync(manifestPath, JSON.stringify({ + deploymentTier: 'preview', + checkoutRoot: '/srv/hypertwist/current', + publicOrigin: 'https://hypertwist.app', + supportEmail: 'hello@hypertwist.app', + publicDocsUrl: 'https://hypertwist.app/resources', + releaseNotesUrl: 'https://hypertwist.app/changelog', + correspondingSourceUrl: 'https://git.scriptoriumai.io/scriptoriumadmin/hypertwist', + openSourceRepoUrl: 'https://git.scriptoriumai.io/scriptoriumadmin/hypertwist', + operatorCheckoutUrl: '', + windowsDownloadUrl: '', + releaseManifestVersion: '', + windowsRelease: { + buildId: '', + sha256: '', + publishedAt: '', + }, + 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, + '--archive-source', + 'worktree', + '--dry-run', + '--json', + ], + { + cwd: websiteRoot, + encoding: 'utf8', + }, + ) + + expect(result.status).toBe(0) + expect(result.stderr).toBe('') + + const payload = JSON.parse(result.stdout) + expect(payload.vpsUser).toBe('root') + expect(payload.archiveSource).toBe('worktree') + expect(payload.manifest.server.paddleWebhookSecret).toBe('su***en') + expect(payload.deployScript).toContain('__HYPERTWIST_LIVE_DEPLOY_SUMMARY__') + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } + }) +}) diff --git a/website/scripts/run-vps-same-origin-live-deploy-lib.mjs b/website/scripts/run-vps-same-origin-live-deploy-lib.mjs new file mode 100644 index 0000000..65740cf --- /dev/null +++ b/website/scripts/run-vps-same-origin-live-deploy-lib.mjs @@ -0,0 +1,299 @@ +import fs from 'node:fs' +import path from 'node:path' +import posixPath from 'node:path/posix' + +import { buildSshArgs, sanitizeStagingProofManifest } from './run-vps-same-origin-staging-proof-lib.mjs' + +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 normalizeArchiveSource(value, fallback = 'git-head') { + const normalized = normalizeTrimmed(value).toLowerCase() + if (normalized === 'worktree') { + return 'worktree' + } + if (normalized === 'git' || normalized === 'head' || normalized === 'git-head') { + return 'git-head' + } + return fallback +} + +export function parseLiveDeployArgs(argv) { + const options = { + manifest: '', + identityFile: '', + vpsHost: '212.227.13.220', + vpsUser: 'root', + archiveSource: 'git-head', + bundleDir: '', + healthUrl: '', + waitSeconds: '30', + keepBundleDir: false, + skipLiveValidation: 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 '--archive-source': + options.archiveSource = next + index += 1 + break + case '--bundle-dir': + options.bundleDir = next + index += 1 + break + case '--health-url': + options.healthUrl = next + index += 1 + break + case '--wait-seconds': + options.waitSeconds = next + index += 1 + break + case '--keep-bundle-dir': + options.keepBundleDir = true + break + case '--skip-live-validation': + options.skipLiveValidation = true + break + case '--json': + options.json = true + break + case '--dry-run': + options.dryRun = true + break + default: + break + } + } + + return options +} + +export function loadLiveDeployManifest(filePath) { + const absolutePath = path.resolve(filePath) + const raw = fs.readFileSync(absolutePath, 'utf8') + return { + path: absolutePath, + manifest: JSON.parse(raw), + } +} + +export function buildDefaultLiveDeployBundleDir(gitRef = 'head') { + const normalized = normalizeTrimmed(gitRef).replace(/[^a-zA-Z0-9._-]/g, '-') + return `/tmp/hypertwist-live-deploy-${normalized || 'head'}` +} + +export function resolveLiveDeployOptions({ + manifestPath, + identityFile, + vpsHost, + vpsUser, + archiveSource, + bundleDir, + healthUrl, + waitSeconds, + keepBundleDir, + skipLiveValidation, + json, + dryRun, + manifest, + gitRef, +}) { + const checkoutRoot = requireNonEmpty(manifest?.checkoutRoot || '/srv/hypertwist/current', 'manifest.checkoutRoot') + const publicOrigin = requireNonEmpty(manifest?.publicOrigin || '', 'manifest.publicOrigin') + + return { + manifestPath: path.resolve(requireNonEmpty(manifestPath, 'manifestPath')), + identityFile: path.resolve(requireNonEmpty(identityFile, 'identityFile')), + vpsHost: requireNonEmpty(vpsHost || '212.227.13.220', 'vpsHost'), + vpsUser: requireNonEmpty(vpsUser || 'root', 'vpsUser'), + archiveSource: normalizeArchiveSource(archiveSource, 'git-head'), + bundleDir: normalizeTrimmed(bundleDir) || buildDefaultLiveDeployBundleDir(gitRef), + checkoutRoot, + healthUrl: normalizeTrimmed(healthUrl) || publicOrigin, + waitSeconds: requirePositiveInteger(waitSeconds || '30', 'waitSeconds'), + keepBundleDir: normalizeBoolean(keepBundleDir, false), + skipLiveValidation: normalizeBoolean(skipLiveValidation, false), + json: normalizeBoolean(json, false), + dryRun: normalizeBoolean(dryRun, false), + } +} + +export function buildStageCheckoutRemoteCommand(checkoutRoot) { + const parentDir = posixPath.dirname(checkoutRoot) + return `rm -rf ${JSON.stringify(checkoutRoot)} && mkdir -p ${JSON.stringify(parentDir)} ${JSON.stringify(checkoutRoot)} && tar -xf - -C ${JSON.stringify(checkoutRoot)}` +} + +export function buildStageBundleRemoteCommand(bundleDir) { + return `rm -rf ${JSON.stringify(bundleDir)} && mkdir -p ${JSON.stringify(bundleDir)} && tar -xf - -C ${JSON.stringify(bundleDir)}` +} + +export function sanitizeLiveDeployManifest(manifest) { + return sanitizeStagingProofManifest(manifest) +} + +export function buildRemoteLiveDeployScript({ + checkoutRoot, + bundleDir, + keepBundleDir, + manifest, +}) { + const serviceUser = normalizeTrimmed(manifest?.serviceUser || 'hypertwist') + const serviceGroup = normalizeTrimmed(manifest?.serviceGroup || serviceUser) + const billingStatePath = normalizeTrimmed(manifest?.server?.billingStatePath || '/var/lib/hypertwist/auth/hypertwist-billing-state.json') + const billingStateDir = posixPath.dirname(billingStatePath) + + return `set -euo pipefail +CHECKOUT_ROOT=${JSON.stringify(checkoutRoot)} +BUNDLE_DIR=${JSON.stringify(bundleDir)} +SERVICE_USER=${JSON.stringify(serviceUser)} +SERVICE_GROUP=${JSON.stringify(serviceGroup)} +BILLING_STATE_DIR=${JSON.stringify(billingStateDir)} +KEEP_BUNDLE_DIR=${keepBundleDir ? '1' : '0'} +SERVICE_NAME="hypertwist-website-auth-server.service" +NGINX_CONF_NAME="hypertwist-app.conf" +STAMP=$(date -u +%Y%m%dT%H%M%SZ) +CHECKOUT_PARENT=$(dirname "$CHECKOUT_ROOT") +WEBSITE_DIR="$CHECKOUT_ROOT/website" +SERVER_DIR="$WEBSITE_DIR/server" +NGINX_BACKUP="" +PLACEHOLDER_BACKUP="" +cleanup() { + if [ "$KEEP_BUNDLE_DIR" = "1" ]; then + return + fi + rm -rf "$BUNDLE_DIR" +} +trap cleanup EXIT + +if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then + groupadd --system "$SERVICE_GROUP" +fi + +if ! id "$SERVICE_USER" >/dev/null 2>&1; then + useradd --system --create-home --home-dir "$CHECKOUT_PARENT" --shell /usr/sbin/nologin --gid "$SERVICE_GROUP" "$SERVICE_USER" +fi + +install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$CHECKOUT_PARENT" "$CHECKOUT_ROOT" "$(dirname "$BILLING_STATE_DIR")" "$BILLING_STATE_DIR" + +cp "$BUNDLE_DIR/website.env" "$WEBSITE_DIR/.env" +cp "$BUNDLE_DIR/server.env" "$SERVER_DIR/.env" +chown -R "$SERVICE_USER:$SERVICE_GROUP" "$CHECKOUT_ROOT" "$(dirname "$BILLING_STATE_DIR")" + +runuser -u "$SERVICE_USER" -- bash -lc "cd $(printf '%q' "$WEBSITE_DIR") && npm ci && npm --prefix server ci && npm run build" + +systemd-analyze verify "$BUNDLE_DIR/hypertwist-website-auth-server.service" + +if [ -f "/etc/nginx/sites-available/$NGINX_CONF_NAME" ]; then + NGINX_BACKUP="/etc/nginx/sites-available/$NGINX_CONF_NAME.bak-$STAMP" + cp "/etc/nginx/sites-available/$NGINX_CONF_NAME" "$NGINX_BACKUP" +fi + +if [ -f "/var/www/hypertwist/index.html" ]; then + PLACEHOLDER_BACKUP="/var/www/hypertwist/index.html.pre-same-origin-$STAMP" + cp "/var/www/hypertwist/index.html" "$PLACEHOLDER_BACKUP" +fi + +cp "$BUNDLE_DIR/hypertwist-website-auth-server.service" "/etc/systemd/system/$SERVICE_NAME" +cp "$BUNDLE_DIR/hypertwist.app.conf" "/etc/nginx/sites-available/$NGINX_CONF_NAME" +ln -sf "/etc/nginx/sites-available/$NGINX_CONF_NAME" "/etc/nginx/sites-enabled/$NGINX_CONF_NAME" + +systemctl daemon-reload +systemctl enable "$SERVICE_NAME" +systemctl restart "$SERVICE_NAME" +nginx -t +systemctl reload nginx + +SERVICE_STATUS=$(systemctl is-active "$SERVICE_NAME") +NGINX_STATUS=$(systemctl is-active nginx) + +node - "$CHECKOUT_ROOT" "$BUNDLE_DIR" "$SERVICE_STATUS" "$NGINX_STATUS" "$NGINX_BACKUP" "$PLACEHOLDER_BACKUP" <<'__HYPERTWIST_LIVE_DEPLOY_SUMMARY__' +const checkoutRoot = process.argv[2] +const bundleDir = process.argv[3] +const serviceStatus = process.argv[4] +const nginxStatus = process.argv[5] +const nginxBackup = process.argv[6] +const placeholderBackup = process.argv[7] + +const summary = { + ok: serviceStatus === 'active' && nginxStatus === 'active', + checkoutRoot, + bundleDir, + serviceStatus, + nginxStatus, + nginxBackup: nginxBackup || null, + placeholderBackup: placeholderBackup || null, +} + +process.stdout.write(\`__HYPERTWIST_LIVE_DEPLOY_SUMMARY__\${JSON.stringify(summary)}\\n\`) +if (!summary.ok) { + process.exit(1) +} +__HYPERTWIST_LIVE_DEPLOY_SUMMARY__ +` +} + +export function extractLiveDeploySummary(output) { + const marker = '__HYPERTWIST_LIVE_DEPLOY_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)) +} + +export { buildSshArgs } diff --git a/website/scripts/run-vps-same-origin-live-deploy-lib.test.mjs b/website/scripts/run-vps-same-origin-live-deploy-lib.test.mjs new file mode 100644 index 0000000..2524997 --- /dev/null +++ b/website/scripts/run-vps-same-origin-live-deploy-lib.test.mjs @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' + +import { + buildDefaultLiveDeployBundleDir, + buildRemoteLiveDeployScript, + buildStageBundleRemoteCommand, + buildStageCheckoutRemoteCommand, + buildSshArgs, + extractLiveDeploySummary, + resolveLiveDeployOptions, + sanitizeLiveDeployManifest, +} from './run-vps-same-origin-live-deploy-lib.mjs' + +function createManifest() { + return { + deploymentTier: 'preview', + checkoutRoot: '/srv/hypertwist/current', + publicOrigin: 'https://hypertwist.app', + serviceUser: 'hypertwist', + serviceGroup: 'hypertwist', + supportEmail: 'hello@hypertwist.app', + publicDocsUrl: 'https://hypertwist.app/resources', + releaseNotesUrl: 'https://hypertwist.app/changelog', + correspondingSourceUrl: 'https://git.scriptoriumai.io/scriptoriumadmin/hypertwist', + openSourceRepoUrl: 'https://git.scriptoriumai.io/scriptoriumadmin/hypertwist', + operatorCheckoutUrl: '', + windowsDownloadUrl: '', + releaseManifestVersion: '', + windowsRelease: { + buildId: '', + sha256: '', + publishedAt: '', + }, + server: { + port: '3011', + billingStatePath: '/var/lib/hypertwist/auth/hypertwist-billing-state.json', + paddleWebhookSecret: 'super-secret-token', + }, + } +} + +describe('resolveLiveDeployOptions', () => { + it('normalizes the required CLI inputs', () => { + const options = resolveLiveDeployOptions({ + manifestPath: 'deploy/hypertwist.same-origin.bundle.json', + identityFile: '/tmp/key', + vpsHost: '212.227.13.220', + vpsUser: 'root', + archiveSource: 'worktree', + bundleDir: '', + healthUrl: '', + waitSeconds: '45', + keepBundleDir: true, + skipLiveValidation: false, + json: true, + dryRun: false, + manifest: createManifest(), + gitRef: 'abc1234', + }) + + expect(options.vpsUser).toBe('root') + expect(options.archiveSource).toBe('worktree') + expect(options.bundleDir).toBe('/tmp/hypertwist-live-deploy-abc1234') + expect(options.healthUrl).toBe('https://hypertwist.app') + expect(options.waitSeconds).toBe(45) + expect(options.keepBundleDir).toBe(true) + }) +}) + +describe('sanitizeLiveDeployManifest', () => { + it('masks secret-like values before dry-run output', () => { + const sanitized = sanitizeLiveDeployManifest(createManifest()) + + expect(sanitized.server.paddleWebhookSecret).toBe('su***en') + }) +}) + +describe('buildRemoteLiveDeployScript', () => { + it('includes the expected root-owned deployment steps and summary marker', () => { + const script = buildRemoteLiveDeployScript({ + checkoutRoot: '/srv/hypertwist/current', + bundleDir: '/tmp/hypertwist-live-deploy-abc1234', + keepBundleDir: false, + manifest: createManifest(), + }) + + expect(script).toContain('useradd --system') + expect(script).toContain('npm ci && npm --prefix server ci && npm run build') + expect(script).toContain('systemctl restart "$SERVICE_NAME"') + expect(script).toContain('nginx -t') + expect(script).toContain('__HYPERTWIST_LIVE_DEPLOY_SUMMARY__') + }) +}) + +describe('extractLiveDeploySummary', () => { + it('parses the emitted summary marker from remote output', () => { + const summary = extractLiveDeploySummary([ + '[live_checkout_root] /srv/hypertwist/current', + '__HYPERTWIST_LIVE_DEPLOY_SUMMARY__{"ok":true,"serviceStatus":"active","nginxStatus":"active"}', + ].join('\n')) + + expect(summary).toEqual({ + ok: true, + serviceStatus: 'active', + nginxStatus: 'active', + }) + }) +}) + +describe('ssh helpers', () => { + it('builds the archive and ssh command shapes', () => { + expect(buildDefaultLiveDeployBundleDir('abc1234')).toBe('/tmp/hypertwist-live-deploy-abc1234') + expect(buildStageCheckoutRemoteCommand('/srv/hypertwist/current')).toContain('tar -xf - -C "/srv/hypertwist/current"') + expect(buildStageBundleRemoteCommand('/tmp/hypertwist-live-deploy-abc1234')).toContain('tar -xf - -C "/tmp/hypertwist-live-deploy-abc1234"') + expect(buildSshArgs({ + identityFile: '/tmp/key', + vpsUser: 'root', + vpsHost: '212.227.13.220', + remoteCommand: 'echo hello', + })).toEqual([ + '-i', + '/tmp/key', + '-o', + 'StrictHostKeyChecking=no', + 'root@212.227.13.220', + 'echo hello', + ]) + }) +}) diff --git a/website/scripts/run-vps-same-origin-live-deploy.mjs b/website/scripts/run-vps-same-origin-live-deploy.mjs new file mode 100644 index 0000000..3e50e41 --- /dev/null +++ b/website/scripts/run-vps-same-origin-live-deploy.mjs @@ -0,0 +1,293 @@ +#!/usr/bin/env node + +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { execFileSync, spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +import { + buildRuntimeReadinessReport, + fetchLiveAuthHealth, + fetchLiveReleaseManifest, + fetchLiveWebsiteShell, + formatRuntimeReadinessReport, + parseEnvFile, +} from './runtime-readiness-lib.mjs' +import { writeSameOriginBundle } from './render-same-origin-bundle-lib.mjs' +import { + buildDefaultLiveDeployBundleDir, + buildRemoteLiveDeployScript, + buildSshArgs, + buildStageBundleRemoteCommand, + buildStageCheckoutRemoteCommand, + extractLiveDeploySummary, + loadLiveDeployManifest, + parseLiveDeployArgs, + resolveLiveDeployOptions, + sanitizeLiveDeployManifest, +} from './run-vps-same-origin-live-deploy-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 buildArchiveBuffer(repoRootPath, archiveSource) { + if (archiveSource === 'worktree') { + return execFileSync('tar', [ + '--exclude=website/node_modules', + '--exclude=website/dist', + '--exclude=website/coverage', + '--exclude=website/server/node_modules', + '--exclude=website/server/dist', + '-cf', + '-', + 'website', + ], { + cwd: repoRootPath, + encoding: null, + maxBuffer: 64 * 1024 * 1024, + }) + } + + return execFileSync('git', ['archive', '--format=tar', 'HEAD', 'website'], { + cwd: repoRootPath, + encoding: null, + maxBuffer: 64 * 1024 * 1024, + }) +} + +function buildRenderedBundleArchive(renderedDir) { + return execFileSync('tar', ['-cf', '-', '-C', renderedDir, '.'], { + encoding: null, + maxBuffer: 64 * 1024 * 1024, + }) +} + +function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +async function waitForLiveAuthHealth(baseUrl, waitSeconds) { + let lastError = null + + for (let attempt = 1; attempt <= waitSeconds; attempt += 1) { + try { + return await fetchLiveAuthHealth(baseUrl) + } catch (error) { + lastError = error + if (attempt < waitSeconds) { + await sleep(1000) + } + } + } + + throw lastError || new Error(`Live auth health did not become ready within ${waitSeconds} seconds.`) +} + +async function main() { + const rawArgs = parseLiveDeployArgs(process.argv.slice(2)) + const gitRef = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8', + }).trim() + + const { path: manifestPath, manifest } = loadLiveDeployManifest(rawArgs.manifest) + const options = resolveLiveDeployOptions({ + manifestPath, + identityFile: rawArgs.identityFile, + vpsHost: rawArgs.vpsHost, + vpsUser: rawArgs.vpsUser, + archiveSource: rawArgs.archiveSource, + bundleDir: rawArgs.bundleDir, + healthUrl: rawArgs.healthUrl, + waitSeconds: rawArgs.waitSeconds, + keepBundleDir: rawArgs.keepBundleDir, + skipLiveValidation: rawArgs.skipLiveValidation, + json: rawArgs.json, + dryRun: rawArgs.dryRun, + manifest, + gitRef, + }) + + const deployScript = buildRemoteLiveDeployScript({ + checkoutRoot: options.checkoutRoot, + bundleDir: options.bundleDir, + keepBundleDir: options.keepBundleDir, + manifest, + }) + const checkoutStageCommand = buildStageCheckoutRemoteCommand(options.checkoutRoot) + const bundleStageCommand = buildStageBundleRemoteCommand(options.bundleDir) + const sanitizedManifest = sanitizeLiveDeployManifest(manifest) + + if (options.dryRun) { + const payload = { + repoRoot, + websiteRoot, + manifestPath, + vpsHost: options.vpsHost, + vpsUser: options.vpsUser, + archiveSource: options.archiveSource, + checkoutRoot: options.checkoutRoot, + bundleDir: options.bundleDir, + healthUrl: options.healthUrl, + waitSeconds: options.waitSeconds, + keepBundleDir: options.keepBundleDir, + skipLiveValidation: options.skipLiveValidation, + checkoutStageCommand, + bundleStageCommand, + manifest: sanitizedManifest, + deployScript, + defaultBundleDirForGitRef: buildDefaultLiveDeployBundleDir(gitRef), + } + + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`) + } else { + process.stdout.write(`[live-deploy] manifest: ${manifestPath}\n`) + process.stdout.write(`[live-deploy] vps: ${options.vpsUser}@${options.vpsHost}\n`) + process.stdout.write(`[live-deploy] archive source: ${options.archiveSource}\n`) + process.stdout.write(`[live-deploy] checkout root: ${options.checkoutRoot}\n`) + process.stdout.write(`[live-deploy] bundle dir: ${options.bundleDir}\n`) + process.stdout.write(`[live-deploy] health url: ${options.healthUrl}\n`) + } + return + } + + const localBundleDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hypertwist-live-deploy-')) + + try { + const rendered = writeSameOriginBundle({ + manifest, + outDir: localBundleDir, + }) + const frontendEnv = parseEnvFile(rendered.frontendEnvContent) + const serverEnv = parseEnvFile(rendered.serverEnvContent) + const checkoutArchiveBuffer = buildArchiveBuffer(repoRoot, options.archiveSource) + const bundleArchiveBuffer = buildRenderedBundleArchive(localBundleDir) + + const checkoutStageResult = runCommand('ssh', buildSshArgs({ + identityFile: options.identityFile, + vpsUser: options.vpsUser, + vpsHost: options.vpsHost, + remoteCommand: checkoutStageCommand, + }), { + input: checkoutArchiveBuffer, + encoding: 'utf8', + }) + + if (checkoutStageResult.status !== 0) { + process.stderr.write(checkoutStageResult.stderr || '') + process.exit(checkoutStageResult.status || 1) + } + + const bundleStageResult = runCommand('ssh', buildSshArgs({ + identityFile: options.identityFile, + vpsUser: options.vpsUser, + vpsHost: options.vpsHost, + remoteCommand: bundleStageCommand, + }), { + input: bundleArchiveBuffer, + encoding: 'utf8', + }) + + if (bundleStageResult.status !== 0) { + process.stderr.write(bundleStageResult.stderr || '') + process.exit(bundleStageResult.status || 1) + } + + const deployResult = runCommand('ssh', [ + ...buildSshArgs({ + identityFile: options.identityFile, + vpsUser: options.vpsUser, + vpsHost: options.vpsHost, + }), + 'bash', + '-s', + ], { + input: deployScript, + }) + + process.stderr.write(deployResult.stderr || '') + const deploySummary = extractLiveDeploySummary(deployResult.stdout || '') + + let liveHealth = null + let liveReleaseManifest = null + let liveWebsiteShell = null + const attemptedLiveValidation = !options.skipLiveValidation + + if (!options.skipLiveValidation) { + liveHealth = await waitForLiveAuthHealth(options.healthUrl, options.waitSeconds) + liveReleaseManifest = await fetchLiveReleaseManifest(options.healthUrl) + liveWebsiteShell = await fetchLiveWebsiteShell(options.healthUrl) + } + + const readinessReport = buildRuntimeReadinessReport({ + frontendEnv, + serverEnv, + liveHealth, + liveReleaseManifest, + liveWebsiteShell, + liveHealthAttempted: attemptedLiveValidation, + liveReleaseManifestAttempted: attemptedLiveValidation, + liveWebsiteShellAttempted: attemptedLiveValidation, + frontendEnvPath: path.join(localBundleDir, 'website.env'), + serverEnvPath: path.join(localBundleDir, 'server.env'), + healthBaseUrl: options.healthUrl, + deploymentTier: manifest.deploymentTier || manifest?.server?.deploymentTier || '', + }) + + const payload = { + ok: deployResult.status === 0 && readinessReport.ok, + manifestPath, + vpsHost: options.vpsHost, + vpsUser: options.vpsUser, + archiveSource: options.archiveSource, + checkoutRoot: options.checkoutRoot, + bundleDir: options.bundleDir, + healthUrl: options.healthUrl, + deploySummary, + readinessReport, + liveHealth, + liveReleaseManifest, + liveWebsiteShell, + } + + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`) + } else { + if (deploySummary) { + process.stdout.write(`[live-deploy] service status: ${deploySummary.serviceStatus}\n`) + process.stdout.write(`[live-deploy] nginx status: ${deploySummary.nginxStatus}\n`) + } + process.stdout.write(`${formatRuntimeReadinessReport(readinessReport)}\n`) + } + + if (deployResult.status !== 0 || !readinessReport.ok) { + process.exit(deployResult.status || 1) + } + } finally { + fs.rmSync(localBundleDir, { recursive: true, force: true }) + } +} + +main().catch((error) => { + process.stderr.write(`${error.stack || error.message}\n`) + process.exit(1) +}) diff --git a/website/server/README.md b/website/server/README.md index db285c4..df337c4 100644 --- a/website/server/README.md +++ b/website/server/README.md @@ -105,6 +105,10 @@ The server now also supports a bounded first-party same-origin deployment mode: while public launch still requires real checkout/download/webhook values - the staging-proof helper now also supports `--archive-source worktree` when operators need real host proof for the exact in-progress packet before commit +- the same package now also ships `npm run run:vps-same-origin-live-deploy` + for the bounded root-owned checkout/env/build/systemd/nginx replacement flow + that was used to cut `https://hypertwist.app` over to the first-party + same-origin preview lane and then re-proved idempotently The website package now also ships a deploy-time verification command: