From 576de0df1cb45fb1be218e3de5bf86b983913a03 Mon Sep 17 00:00:00 2001 From: axiomlogicnexus Date: Mon, 22 Jun 2026 02:36:28 +0000 Subject: [PATCH] Add website deployment readiness check --- ...LING_AND_DISTRIBUTION_PACKET_2026-06-22.md | 9 + ..._RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md | 10 + .../HyperTwist/FEATURE_REGISTRY.md | 2 +- .../HyperTwist/ROADMAP.md | 4 +- website/README.md | 6 + website/package.json | 3 +- website/scripts/check-runtime-readiness.mjs | 102 +++++ website/scripts/runtime-readiness-lib.mjs | 381 ++++++++++++++++++ .../scripts/runtime-readiness-lib.test.mjs | 105 +++++ website/server/README.md | 9 + 10 files changed, 628 insertions(+), 3 deletions(-) create mode 100644 website/scripts/check-runtime-readiness.mjs create mode 100644 website/scripts/runtime-readiness-lib.mjs create mode 100644 website/scripts/runtime-readiness-lib.test.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 850a3a9..dab2811 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 @@ -125,6 +125,15 @@ The login and register pages now also surface those shared-auth runtime warnings instead of silently behaving like production auth when the lane is still in local fallback or mixed deployment posture. +The website package now also includes a first-party external runtime-readiness +command under: + +- `website/scripts/check-runtime-readiness.mjs` + +That verifier reads frontend plus server env posture, optionally checks live +`/api/auth/health`, and fails when the current lane is still missing public +download, checkout, cookie-hardening, secret, or source/notices configuration. + This is browser-based user access for the operator/account surface. It is **not** a claim that the simulator itself is now browser-owned. 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 67f28ff..111875d 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 @@ -80,6 +80,16 @@ Before public launch, the current runtime and dashboard surfaces should show: - no runtime config warnings that indicate loopback, insecure `http`, or partial OAuth setup - dashboard launch-readiness issues cleared for download/check-out/source configuration +The current first-party external verification command is: + +```bash +cd /home/dev/src/HyperTwist/website +npm run check:runtime-readiness -- --frontend-env .env --server-env server/.env --health-url https://hypertwist.app +``` + +Use `--skip-live-health` when a deployed endpoint is not reachable yet and only +env-file posture should be checked. + ## Related authorities - `website/README.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 e221384..d1d7489 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. | +| 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. | | 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. | | 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 public `/download` page now keeps raw download URLs behind the protected dashboard instead of exposing them directly. 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`. 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 c25c86e..1ed99a0 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md @@ -213,7 +213,9 @@ Current consolidated milestone snapshot: website/server lane now also has first-party runtime-configuration diagnostics for local versus mixed versus public auth posture and the login/register surfaces now expose those warnings instead of silently - resembling a production-ready lane, + resembling a production-ready lane, and the `website/` package now also ships + a first-party external runtime-readiness verifier for env plus live + `/api/auth/health` posture before public launch, persists a bounded first-party billing-state file, applies verified Paddle events into account/download entitlement state, and surfaces that resolved billing/download posture back through `/api/auth/me`, the protected browser diff --git a/website/README.md b/website/README.md index 89b9b71..e2ffd3b 100644 --- a/website/README.md +++ b/website/README.md @@ -45,6 +45,7 @@ Frontend default URL: npm run type-check npm run test npm run build +npm run check:runtime-readiness -- --frontend-env .env --server-env server/.env --health-url https://hypertwist.app ``` ## Related surfaces @@ -70,3 +71,8 @@ notices and corresponding-source destination. Recommended production posture is documented in: - `docs/ops/HYPERTWIST_WEBSITE_RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md` + +Use the runtime-readiness command before public launch or deployment approval: + +- it fails if required public launch values are still missing +- it can optionally verify live `/api/auth/health` posture from the deployed site diff --git a/website/package.json b/website/package.json index 7c89cd0..9354d3b 100644 --- a/website/package.json +++ b/website/package.json @@ -9,7 +9,8 @@ "build": "tsc && vite build", "preview": "vite preview", "type-check": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run", + "check:runtime-readiness": "node scripts/check-runtime-readiness.mjs" }, "dependencies": { "@tanstack/react-query": "^5.83.0", diff --git a/website/scripts/check-runtime-readiness.mjs b/website/scripts/check-runtime-readiness.mjs new file mode 100644 index 0000000..a8a0e10 --- /dev/null +++ b/website/scripts/check-runtime-readiness.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + buildRuntimeReadinessReport, + deriveHealthBaseUrl, + fetchLiveAuthHealth, + formatRuntimeReadinessReport, + loadEnvFile, +} from './runtime-readiness-lib.mjs' + +function parseArgs(argv) { + const options = { + frontendEnv: '', + serverEnv: '', + healthUrl: '', + skipLiveHealth: false, + json: false, + } + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index] + if (argument === '--frontend-env') { + options.frontendEnv = argv[index + 1] || '' + index += 1 + continue + } + if (argument === '--server-env') { + options.serverEnv = argv[index + 1] || '' + index += 1 + continue + } + if (argument === '--health-url') { + options.healthUrl = argv[index + 1] || '' + index += 1 + continue + } + if (argument === '--skip-live-health') { + options.skipLiveHealth = true + continue + } + if (argument === '--json') { + options.json = true + } + } + + return options +} + +const currentFile = fileURLToPath(import.meta.url) +const websiteRoot = path.resolve(path.dirname(currentFile), '..') +const args = parseArgs(process.argv.slice(2)) + +const frontendEnvFile = loadEnvFile(args.frontendEnv || path.join(websiteRoot, '.env')) +const serverEnvFile = loadEnvFile(args.serverEnv || path.join(websiteRoot, 'server', '.env')) + +let preloadFailures = [] +if (!frontendEnvFile.exists) { + preloadFailures.push(`Frontend env file not found: ${frontendEnvFile.path}`) +} +if (!serverEnvFile.exists) { + preloadFailures.push(`Server env file not found: ${serverEnvFile.path}`) +} + +const healthBaseUrl = deriveHealthBaseUrl({ + explicitHealthUrl: args.healthUrl, + frontendEnv: frontendEnvFile.values, + serverEnv: serverEnvFile.values, +}) + +let liveHealth = null +if (!args.skipLiveHealth && healthBaseUrl) { + try { + liveHealth = await fetchLiveAuthHealth(healthBaseUrl) + } catch (error) { + preloadFailures.push(`Live auth health check failed: ${error instanceof Error ? error.message : String(error)}`) + } +} + +const report = buildRuntimeReadinessReport({ + frontendEnv: frontendEnvFile.values, + serverEnv: serverEnvFile.values, + liveHealth, + frontendEnvPath: frontendEnvFile.path, + serverEnvPath: serverEnvFile.path, + healthBaseUrl, +}) + +if (preloadFailures.length > 0) { + report.failures.unshift(...preloadFailures) + report.ok = false +} + +if (args.json) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`) +} else { + process.stdout.write(formatRuntimeReadinessReport(report)) +} + +process.exitCode = report.ok ? 0 : 1 diff --git a/website/scripts/runtime-readiness-lib.mjs b/website/scripts/runtime-readiness-lib.mjs new file mode 100644 index 0000000..444e370 --- /dev/null +++ b/website/scripts/runtime-readiness-lib.mjs @@ -0,0 +1,381 @@ +import fs from 'node:fs' +import path from 'node:path' + +function normalizeTrimmed(value) { + return String(value || '').trim() +} + +function stripOptionalQuotes(value) { + const trimmed = normalizeTrimmed(value) + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) + || (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1) + } + return trimmed +} + +function tryParseUrl(value) { + const trimmed = normalizeTrimmed(value) + if (!trimmed) { + return null + } + + try { + return new URL(trimmed) + } catch { + return null + } +} + +function isLoopbackHostname(hostname) { + const normalized = normalizeTrimmed(hostname).toLowerCase() + return normalized === 'localhost' + || normalized === '127.0.0.1' + || normalized === '::1' + || normalized === '[::1]' +} + +function isHttpsUrl(url) { + return url?.protocol === 'https:' +} + +function createBucket() { + return { + failures: [], + warnings: [], + } +} + +function pushFailure(bucket, message) { + bucket.failures.push(message) +} + +function pushWarning(bucket, message) { + bucket.warnings.push(message) +} + +function requireNonEmpty(bucket, label, value) { + if (!normalizeTrimmed(value)) { + pushFailure(bucket, `${label} is missing.`) + return false + } + return true +} + +function requireAbsoluteUrl(bucket, label, value) { + if (!requireNonEmpty(bucket, label, value)) { + return null + } + + const parsed = tryParseUrl(value) + if (!parsed) { + pushFailure(bucket, `${label} must be an absolute URL.`) + return null + } + return parsed +} + +function evaluateFrontendConfig(frontendEnv) { + const bucket = createBucket() + + const superTokensApiDomain = requireAbsoluteUrl(bucket, 'VITE_SUPERTOKENS_API_DOMAIN', frontendEnv.VITE_SUPERTOKENS_API_DOMAIN) + const superTokensWebsiteDomain = requireAbsoluteUrl(bucket, 'VITE_SUPERTOKENS_WEBSITE_DOMAIN', frontendEnv.VITE_SUPERTOKENS_WEBSITE_DOMAIN) + const authApiBaseUrl = requireAbsoluteUrl(bucket, 'VITE_AUTH_API_BASE_URL', frontendEnv.VITE_AUTH_API_BASE_URL) + + if (!requireNonEmpty(bucket, 'VITE_WINDOWS_DOWNLOAD_URL', frontendEnv.VITE_WINDOWS_DOWNLOAD_URL)) { + // already recorded as failure + } else if (!tryParseUrl(frontendEnv.VITE_WINDOWS_DOWNLOAD_URL)) { + pushFailure(bucket, 'VITE_WINDOWS_DOWNLOAD_URL must be an absolute URL.') + } + + if (!requireNonEmpty(bucket, 'VITE_PADDLE_CHECKOUT_URL_OPERATOR', frontendEnv.VITE_PADDLE_CHECKOUT_URL_OPERATOR)) { + // already recorded as failure + } else if (!tryParseUrl(frontendEnv.VITE_PADDLE_CHECKOUT_URL_OPERATOR)) { + pushFailure(bucket, 'VITE_PADDLE_CHECKOUT_URL_OPERATOR must be an absolute URL.') + } + + if (!requireNonEmpty(bucket, 'VITE_MPL_SOURCE_URL', frontendEnv.VITE_MPL_SOURCE_URL)) { + // already recorded as failure + } else if (!tryParseUrl(frontendEnv.VITE_MPL_SOURCE_URL)) { + pushFailure(bucket, 'VITE_MPL_SOURCE_URL must be an absolute URL.') + } + + if (!requireNonEmpty(bucket, 'VITE_OPEN_SOURCE_REPO_URL', frontendEnv.VITE_OPEN_SOURCE_REPO_URL)) { + // already recorded as failure + } else if (!tryParseUrl(frontendEnv.VITE_OPEN_SOURCE_REPO_URL)) { + pushFailure(bucket, 'VITE_OPEN_SOURCE_REPO_URL must be an absolute URL.') + } + + for (const [label, url] of [ + ['VITE_SUPERTOKENS_API_DOMAIN', superTokensApiDomain], + ['VITE_SUPERTOKENS_WEBSITE_DOMAIN', superTokensWebsiteDomain], + ['VITE_AUTH_API_BASE_URL', authApiBaseUrl], + ]) { + if (!url) continue + if (isLoopbackHostname(url.hostname)) { + pushFailure(bucket, `${label} still targets a loopback/local-development origin.`) + } else if (!isHttpsUrl(url)) { + pushFailure(bucket, `${label} uses HTTP on a non-loopback origin; use HTTPS before public launch.`) + } + } + + if ( + superTokensApiDomain + && authApiBaseUrl + && normalizeTrimmed(superTokensApiDomain.origin) !== normalizeTrimmed(authApiBaseUrl.origin) + ) { + pushWarning(bucket, 'VITE_AUTH_API_BASE_URL differs from VITE_SUPERTOKENS_API_DOMAIN; verify both point at the same auth backend.') + } + + if ( + superTokensApiDomain + && superTokensWebsiteDomain + && superTokensApiDomain.origin !== superTokensWebsiteDomain.origin + ) { + pushWarning(bucket, 'Frontend auth API and website origins differ; same-origin public posture is the clean default.') + } + + if (!normalizeTrimmed(frontendEnv.VITE_PADDLE_CHECKOUT_URL_STUDIO)) { + pushWarning(bucket, 'VITE_PADDLE_CHECKOUT_URL_STUDIO is not set; Studio pricing will stay on the contact/support fallback.') + } + + return { + ...bucket, + authApiOrigin: authApiBaseUrl?.origin || '', + websiteOrigin: superTokensWebsiteDomain?.origin || '', + } +} + +function evaluateServerConfig(serverEnv) { + const bucket = createBucket() + + const apiDomain = requireAbsoluteUrl(bucket, 'API_DOMAIN', serverEnv.API_DOMAIN) + const websiteDomain = requireAbsoluteUrl(bucket, 'WEBSITE_DOMAIN', serverEnv.WEBSITE_DOMAIN) + const superTokensCoreUri = requireAbsoluteUrl(bucket, 'SUPERTOKENS_CORE_URI', serverEnv.SUPERTOKENS_CORE_URI) + const cookieSecure = normalizeTrimmed(serverEnv.COOKIE_SECURE).toLowerCase() === 'true' + + if (!cookieSecure) { + pushFailure(bucket, 'COOKIE_SECURE must be true before public launch.') + } + + if (!normalizeTrimmed(serverEnv.PADDLE_WEBHOOK_SECRET)) { + pushFailure(bucket, 'PADDLE_WEBHOOK_SECRET is missing.') + } + + for (const [label, url] of [ + ['API_DOMAIN', apiDomain], + ['WEBSITE_DOMAIN', websiteDomain], + ]) { + if (!url) continue + if (isLoopbackHostname(url.hostname)) { + pushFailure(bucket, `${label} still targets a loopback/local-development origin.`) + } else if (!isHttpsUrl(url)) { + pushFailure(bucket, `${label} uses HTTP on a non-loopback origin; use HTTPS before public launch.`) + } + } + + if (superTokensCoreUri && isLoopbackHostname(superTokensCoreUri.hostname)) { + pushWarning(bucket, 'SUPERTOKENS_CORE_URI still targets a loopback/local-development host.') + } + + if ( + apiDomain + && websiteDomain + && apiDomain.origin !== websiteDomain.origin + ) { + pushWarning(bucket, 'API_DOMAIN and WEBSITE_DOMAIN differ; same-origin public posture is the clean default.') + } + + if ( + Boolean(normalizeTrimmed(serverEnv.GITHUB_CLIENT_ID)) + !== Boolean(normalizeTrimmed(serverEnv.GITHUB_CLIENT_SECRET)) + ) { + pushWarning(bucket, 'GitHub OAuth is only partially configured on the server.') + } + + if ( + Boolean(normalizeTrimmed(serverEnv.GOOGLE_CLIENT_ID)) + !== Boolean(normalizeTrimmed(serverEnv.GOOGLE_CLIENT_SECRET)) + ) { + pushWarning(bucket, 'Google OAuth is only partially configured on the server.') + } + + if ( + !normalizeTrimmed(serverEnv.PADDLE_PRODUCT_PLAN_MAP) + && !normalizeTrimmed(serverEnv.PADDLE_PRICE_PLAN_MAP) + ) { + pushWarning(bucket, 'Neither PADDLE_PRODUCT_PLAN_MAP nor PADDLE_PRICE_PLAN_MAP is configured; billing plan resolution will rely on webhook custom_data only.') + } + + return { + ...bucket, + apiOrigin: apiDomain?.origin || '', + websiteOrigin: websiteDomain?.origin || '', + } +} + +export function parseEnvFile(contents) { + const values = {} + for (const line of String(contents || '').split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) { + continue + } + + const candidate = trimmed.startsWith('export ') ? trimmed.slice(7).trim() : trimmed + const separatorIndex = candidate.indexOf('=') + if (separatorIndex <= 0) { + continue + } + + const key = candidate.slice(0, separatorIndex).trim() + const rawValue = candidate.slice(separatorIndex + 1) + values[key] = stripOptionalQuotes(rawValue) + } + return values +} + +export function loadEnvFile(filePath) { + const absolutePath = path.resolve(filePath) + if (!fs.existsSync(absolutePath)) { + return { + path: absolutePath, + exists: false, + values: {}, + } + } + + return { + path: absolutePath, + exists: true, + values: parseEnvFile(fs.readFileSync(absolutePath, 'utf8')), + } +} + +export function deriveHealthBaseUrl({ explicitHealthUrl, frontendEnv, serverEnv }) { + const explicit = normalizeTrimmed(explicitHealthUrl) + if (explicit) { + return explicit.replace(/\/$/, '') + } + + const frontendAuthBase = normalizeTrimmed(frontendEnv.VITE_AUTH_API_BASE_URL) + if (frontendAuthBase) { + return frontendAuthBase.replace(/\/$/, '') + } + + const serverApiDomain = normalizeTrimmed(serverEnv.API_DOMAIN) + if (serverApiDomain) { + return serverApiDomain.replace(/\/$/, '') + } + + return '' +} + +export async function fetchLiveAuthHealth(baseUrl, fetchImpl = fetch) { + const targetUrl = `${normalizeTrimmed(baseUrl).replace(/\/$/, '')}/api/auth/health` + const response = await fetchImpl(targetUrl, { + headers: { + accept: 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`auth_health_http_${response.status}`) + } + + return response.json() +} + +export function buildRuntimeReadinessReport({ + frontendEnv, + serverEnv, + liveHealth, + frontendEnvPath = '', + serverEnvPath = '', + healthBaseUrl = '', +}) { + const frontend = evaluateFrontendConfig(frontendEnv) + const server = evaluateServerConfig(serverEnv) + const failures = [...frontend.failures, ...server.failures] + const warnings = [...frontend.warnings, ...server.warnings] + + if (frontend.authApiOrigin && server.apiOrigin && frontend.authApiOrigin !== server.apiOrigin) { + warnings.push('Frontend auth base URL and server API domain differ; verify they represent the same public auth backend.') + } + + if (liveHealth) { + if (liveHealth.supertokens?.ready !== true) { + failures.push('Live auth health reports SuperTokens not ready.') + } + if (liveHealth.fallback?.active === true) { + failures.push(`Live auth health reports fallback active${liveHealth.fallback?.reason ? ` (${liveHealth.fallback.reason})` : ''}.`) + } + if (liveHealth.runtime?.public_origin_ready !== true) { + failures.push('Live auth health reports public auth origin not ready.') + } + if (liveHealth.runtime?.mode !== 'public') { + failures.push(`Live auth health reports runtime mode '${liveHealth.runtime?.mode || 'unknown'}' instead of 'public'.`) + } + for (const message of liveHealth.runtime?.errors || []) { + failures.push(`Live runtime error: ${message}`) + } + for (const message of liveHealth.runtime?.warnings || []) { + failures.push(`Live runtime warning: ${message}`) + } + if (liveHealth.billing?.webhookSecretConfigured !== true) { + failures.push('Live auth health reports Paddle webhook secret missing.') + } + if (liveHealth.billing?.productPlanMapConfigured !== true && liveHealth.billing?.pricePlanMapConfigured !== true) { + warnings.push('Live auth health reports no Paddle product/price map configured; custom_data-based resolution remains the only billing-plan path.') + } + } else { + warnings.push('Live auth health was not checked; runtime readiness was evaluated from env posture only.') + } + + return { + ok: failures.length === 0, + failures, + warnings, + frontendEnvPath, + serverEnvPath, + healthBaseUrl, + liveHealthChecked: Boolean(liveHealth), + } +} + +export function formatRuntimeReadinessReport(report) { + const lines = [] + lines.push(`Runtime readiness: ${report.ok ? 'PASS' : 'FAIL'}`) + if (report.frontendEnvPath) { + lines.push(`Frontend env: ${report.frontendEnvPath}`) + } + if (report.serverEnvPath) { + lines.push(`Server env: ${report.serverEnvPath}`) + } + if (report.healthBaseUrl) { + lines.push(`Health base URL: ${report.healthBaseUrl}`) + } + lines.push(`Live health checked: ${report.liveHealthChecked ? 'yes' : 'no'}`) + + if (report.failures.length > 0) { + lines.push('Failures:') + for (const item of report.failures) { + lines.push(`- ${item}`) + } + } + + if (report.warnings.length > 0) { + lines.push('Warnings:') + for (const item of report.warnings) { + lines.push(`- ${item}`) + } + } + + if (report.failures.length === 0 && report.warnings.length === 0) { + lines.push('No failures or warnings.') + } + + return `${lines.join('\n')}\n` +} diff --git a/website/scripts/runtime-readiness-lib.test.mjs b/website/scripts/runtime-readiness-lib.test.mjs new file mode 100644 index 0000000..dd81380 --- /dev/null +++ b/website/scripts/runtime-readiness-lib.test.mjs @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' + +import { + buildRuntimeReadinessReport, + deriveHealthBaseUrl, + parseEnvFile, +} from './runtime-readiness-lib.mjs' + +describe('parseEnvFile', () => { + it('parses simple dotenv-style content with quotes and export prefixes', () => { + const parsed = parseEnvFile(` + # comment + export API_DOMAIN="https://hypertwist.app" + COOKIE_SECURE=true + VITE_SUPPORT_EMAIL='hello@hypertwist.app' + `) + + expect(parsed).toEqual({ + API_DOMAIN: 'https://hypertwist.app', + COOKIE_SECURE: 'true', + VITE_SUPPORT_EMAIL: 'hello@hypertwist.app', + }) + }) +}) + +describe('buildRuntimeReadinessReport', () => { + it('passes a fully configured public same-origin posture', () => { + const report = buildRuntimeReadinessReport({ + frontendEnv: { + VITE_SUPERTOKENS_API_DOMAIN: 'https://hypertwist.app', + VITE_SUPERTOKENS_WEBSITE_DOMAIN: 'https://hypertwist.app', + VITE_AUTH_API_BASE_URL: 'https://hypertwist.app', + VITE_WINDOWS_DOWNLOAD_URL: 'https://downloads.hypertwist.app/windows.exe', + VITE_PADDLE_CHECKOUT_URL_OPERATOR: 'https://buy.paddle.com/operator', + VITE_PADDLE_CHECKOUT_URL_STUDIO: 'https://buy.paddle.com/studio', + VITE_MPL_SOURCE_URL: 'https://hypertwist.app/open-source/source', + VITE_OPEN_SOURCE_REPO_URL: 'https://git.scriptoriumai.io/scriptoriumadmin/hypertwist', + }, + serverEnv: { + API_DOMAIN: 'https://hypertwist.app', + WEBSITE_DOMAIN: 'https://hypertwist.app', + SUPERTOKENS_CORE_URI: 'https://auth-core.internal', + COOKIE_SECURE: 'true', + PADDLE_WEBHOOK_SECRET: 'secret', + PADDLE_PRICE_PLAN_MAP: '{"pri_operator":"operator"}', + }, + liveHealth: { + supertokens: { ready: true }, + fallback: { active: false }, + runtime: { + public_origin_ready: true, + mode: 'public', + errors: [], + warnings: [], + }, + billing: { + webhookSecretConfigured: true, + productPlanMapConfigured: false, + pricePlanMapConfigured: true, + }, + }, + }) + + expect(report.ok).toBe(true) + expect(report.failures).toEqual([]) + }) + + it('fails localhost-grade posture and missing public-launch configuration', () => { + const report = buildRuntimeReadinessReport({ + frontendEnv: { + VITE_SUPERTOKENS_API_DOMAIN: 'http://localhost:3001', + VITE_SUPERTOKENS_WEBSITE_DOMAIN: 'http://localhost:4273', + VITE_AUTH_API_BASE_URL: 'http://localhost:3001', + }, + serverEnv: { + API_DOMAIN: 'http://localhost:3001', + WEBSITE_DOMAIN: 'http://localhost:4273', + SUPERTOKENS_CORE_URI: 'http://localhost:3567', + COOKIE_SECURE: 'false', + }, + liveHealth: null, + }) + + expect(report.ok).toBe(false) + expect(report.failures).toContain('VITE_SUPERTOKENS_API_DOMAIN still targets a loopback/local-development origin.') + expect(report.failures).toContain('COOKIE_SECURE must be true before public launch.') + expect(report.failures).toContain('PADDLE_WEBHOOK_SECRET is missing.') + }) +}) + +describe('deriveHealthBaseUrl', () => { + it('prefers the explicit health URL over env-derived defaults', () => { + const baseUrl = deriveHealthBaseUrl({ + explicitHealthUrl: 'https://hypertwist.app/', + frontendEnv: { + VITE_AUTH_API_BASE_URL: 'https://auth.hypertwist.app', + }, + serverEnv: { + API_DOMAIN: 'https://server.hypertwist.app', + }, + }) + + expect(baseUrl).toBe('https://hypertwist.app') + }) +}) diff --git a/website/server/README.md b/website/server/README.md index 54808e4..bf934b1 100644 --- a/website/server/README.md +++ b/website/server/README.md @@ -74,3 +74,12 @@ Recommended public `hypertwist.app` posture: That recommended same-origin production posture is recorded in: - `docs/ops/HYPERTWIST_WEBSITE_RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md` + +The website package now also ships a deploy-time verification command: + +```bash +cd /home/dev/src/HyperTwist/website +npm run check:runtime-readiness -- --frontend-env .env --server-env server/.env --health-url https://hypertwist.app +``` + +Use `--skip-live-health` when only file-level env verification is possible.