hypertwist/website/scripts/runtime-readiness-lib.mjs
2026-06-22 07:53:11 +00:00

642 lines
22 KiB
JavaScript

import fs from 'node:fs'
import path from 'node:path'
function normalizeTrimmed(value) {
return String(value || '').trim()
}
function normalizeDeploymentTier(value, fallback = 'launch') {
const normalized = normalizeTrimmed(value).toLowerCase()
if (normalized === 'preview' || normalized === 'launch') {
return normalized
}
return fallback
}
function collapseWhitespace(value) {
return normalizeTrimmed(value).replace(/\s+/g, ' ')
}
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 isPlaceholderLike(value) {
const trimmed = normalizeTrimmed(value).toLowerCase()
if (!trimmed) {
return false
}
return trimmed.includes('replace-me')
|| trimmed.includes('replace_me')
|| trimmed.includes('changeme')
|| trimmed.includes('change-me')
|| trimmed.includes('your-')
|| trimmed.includes('your_')
|| trimmed.includes('<')
|| trimmed.includes('todo')
}
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 summarizeResponseBody(value, maxLength = 160) {
const normalized = collapseWhitespace(value)
if (!normalized) {
return ''
}
if (normalized.length <= maxLength) {
return normalized
}
return `${normalized.slice(0, maxLength - 1)}`
}
function looksLikeHtmlResponse(contentType, body) {
const normalizedContentType = normalizeTrimmed(contentType).toLowerCase()
const normalizedBody = normalizeTrimmed(body).toLowerCase()
return normalizedContentType.includes('text/html')
|| normalizedBody.startsWith('<!doctype html')
|| normalizedBody.startsWith('<html')
}
function extractHtmlTitle(html) {
const match = String(html || '').match(/<title>(.*?)<\/title>/i)
return match ? collapseWhitespace(match[1]) : ''
}
function detectPlaceholderRolloutPage(html) {
const normalized = collapseWhitespace(html).toLowerCase()
return normalized.includes('deployment target is live on the new vps')
|| normalized.includes('application rollout is pending')
}
function buildEndpointResponseError(label, response, body) {
const snippet = summarizeResponseBody(body)
const contentType = normalizeTrimmed(response.headers?.get?.('content-type') || '')
if (looksLikeHtmlResponse(contentType, body)) {
const placeholderSuffix = detectPlaceholderRolloutPage(body)
? ' The response still appears to be the placeholder rollout page.'
: ''
return new Error(`${label} returned HTML instead of JSON.${placeholderSuffix}${snippet ? ` Snippet: ${snippet}` : ''}`)
}
return new Error(`${label} returned invalid JSON.${snippet ? ` Snippet: ${snippet}` : ''}`)
}
async function fetchJsonEndpoint(baseUrl, routePath, label, fetchImpl = fetch) {
const targetUrl = `${normalizeTrimmed(baseUrl).replace(/\/$/, '')}${routePath}`
const response = await fetchImpl(targetUrl, {
headers: {
accept: 'application/json',
},
})
const body = await response.text()
if (!response.ok) {
throw new Error(`${label} failed with HTTP ${response.status}.${body ? ` Snippet: ${summarizeResponseBody(body)}` : ''}`)
}
try {
return JSON.parse(body)
} catch {
throw buildEndpointResponseError(label, response, body)
}
}
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 rejectPlaceholderValue(bucket, label, value) {
if (isPlaceholderLike(value)) {
pushFailure(bucket, `${label} still contains a placeholder value.`)
}
}
function resolveEnvCandidate(frontendEnv, serverEnv, frontendKey, serverKey) {
const frontendValue = normalizeTrimmed(frontendEnv[frontendKey])
if (frontendValue) {
return {
label: frontendKey,
value: frontendValue,
}
}
const serverValue = normalizeTrimmed(serverEnv[serverKey])
if (serverValue) {
return {
label: serverKey,
value: serverValue,
}
}
return {
label: `${frontendKey} or ${serverKey}`,
value: '',
}
}
function requireAbsoluteUrlCandidate(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
}
rejectPlaceholderValue(bucket, label, value)
return parsed
}
function evaluateFrontendConfig(frontendEnv, serverEnv, deploymentTier = 'launch') {
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)
const windowsDownloadCandidate = resolveEnvCandidate(frontendEnv, serverEnv, 'VITE_WINDOWS_DOWNLOAD_URL', 'WINDOWS_DOWNLOAD_URL')
if (!normalizeTrimmed(windowsDownloadCandidate.value)) {
if (deploymentTier === 'preview') {
pushWarning(bucket, `${windowsDownloadCandidate.label} is not set; Windows download will remain in preview posture until the release lane is configured.`)
} else {
requireAbsoluteUrlCandidate(bucket, windowsDownloadCandidate.label, windowsDownloadCandidate.value)
}
} else {
requireAbsoluteUrlCandidate(bucket, windowsDownloadCandidate.label, windowsDownloadCandidate.value)
}
if (!normalizeTrimmed(frontendEnv.VITE_PADDLE_CHECKOUT_URL_OPERATOR)) {
if (deploymentTier === 'preview') {
pushWarning(bucket, 'VITE_PADDLE_CHECKOUT_URL_OPERATOR is not set; Operator pricing will stay on the support fallback until checkout is configured.')
} else {
pushFailure(bucket, 'VITE_PADDLE_CHECKOUT_URL_OPERATOR is missing.')
}
} else if (!tryParseUrl(frontendEnv.VITE_PADDLE_CHECKOUT_URL_OPERATOR)) {
pushFailure(bucket, 'VITE_PADDLE_CHECKOUT_URL_OPERATOR must be an absolute URL.')
} else {
rejectPlaceholderValue(bucket, 'VITE_PADDLE_CHECKOUT_URL_OPERATOR', frontendEnv.VITE_PADDLE_CHECKOUT_URL_OPERATOR)
}
const correspondingSourceCandidate = resolveEnvCandidate(frontendEnv, serverEnv, 'VITE_MPL_SOURCE_URL', 'MPL_SOURCE_URL')
requireAbsoluteUrlCandidate(bucket, correspondingSourceCandidate.label, correspondingSourceCandidate.value)
const openSourceRepoCandidate = resolveEnvCandidate(frontendEnv, serverEnv, 'VITE_OPEN_SOURCE_REPO_URL', 'OPEN_SOURCE_REPO_URL')
requireAbsoluteUrlCandidate(bucket, openSourceRepoCandidate.label, openSourceRepoCandidate.value)
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.')
} else {
rejectPlaceholderValue(bucket, 'VITE_PADDLE_CHECKOUT_URL_STUDIO', frontendEnv.VITE_PADDLE_CHECKOUT_URL_STUDIO)
}
return {
...bucket,
authApiOrigin: authApiBaseUrl?.origin || '',
websiteOrigin: superTokensWebsiteDomain?.origin || '',
}
}
function evaluateServerConfig(serverEnv, deploymentTier = 'launch') {
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)) {
if (deploymentTier === 'preview') {
pushWarning(bucket, 'PADDLE_WEBHOOK_SECRET is not set; billing webhook handling will remain in preview posture until the live secret is configured.')
} else {
pushFailure(bucket, 'PADDLE_WEBHOOK_SECRET is missing.')
}
} else {
rejectPlaceholderValue(bucket, 'PADDLE_WEBHOOK_SECRET', serverEnv.PADDLE_WEBHOOK_SECRET)
}
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.')
}
const serveStaticWebsite = normalizeTrimmed(serverEnv.SERVE_STATIC_WEBSITE).toLowerCase()
const websiteDistPath = normalizeTrimmed(serverEnv.WEBSITE_DIST_PATH) || '../dist'
const sameOriginPublicPosture = Boolean(
apiDomain
&& websiteDomain
&& apiDomain.origin === websiteDomain.origin
&& !isLoopbackHostname(apiDomain.hostname)
&& !isLoopbackHostname(websiteDomain.hostname),
)
if (sameOriginPublicPosture) {
if (serveStaticWebsite === 'false' || serveStaticWebsite === '0' || serveStaticWebsite === 'no') {
pushWarning(bucket, 'SERVE_STATIC_WEBSITE=false; ensure a separate same-origin web server serves the built website bundle.')
} else if (serveStaticWebsite !== 'true' && serveStaticWebsite !== '1' && serveStaticWebsite !== 'yes') {
pushWarning(bucket, `SERVE_STATIC_WEBSITE is not explicitly set; confirm ${websiteDistPath} is present for first-party same-origin serving or that an external same-origin web server serves the frontend.`)
}
}
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.')
}
if (normalizeTrimmed(serverEnv.PADDLE_PRODUCT_PLAN_MAP)) {
rejectPlaceholderValue(bucket, 'PADDLE_PRODUCT_PLAN_MAP', serverEnv.PADDLE_PRODUCT_PLAN_MAP)
}
if (normalizeTrimmed(serverEnv.PADDLE_PRICE_PLAN_MAP)) {
rejectPlaceholderValue(bucket, 'PADDLE_PRICE_PLAN_MAP', serverEnv.PADDLE_PRICE_PLAN_MAP)
}
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) {
return fetchJsonEndpoint(baseUrl, '/api/auth/health', 'Live auth health endpoint', fetchImpl)
}
export async function fetchLiveReleaseManifest(baseUrl, fetchImpl = fetch) {
return fetchJsonEndpoint(baseUrl, '/api/releases/manifest', 'Live release manifest endpoint', fetchImpl)
}
export async function fetchLiveWebsiteShell(baseUrl, fetchImpl = fetch) {
const targetUrl = `${normalizeTrimmed(baseUrl).replace(/\/$/, '')}/`
const response = await fetchImpl(targetUrl, {
headers: {
accept: 'text/html,application/xhtml+xml',
},
})
const html = await response.text()
const contentType = normalizeTrimmed(response.headers?.get?.('content-type') || '')
if (!response.ok) {
throw new Error(`Live website root failed with HTTP ${response.status}.${html ? ` Snippet: ${summarizeResponseBody(html)}` : ''}`)
}
if (!looksLikeHtmlResponse(contentType, html)) {
throw new Error(`Live website root did not return HTML.${html ? ` Snippet: ${summarizeResponseBody(html)}` : ''}`)
}
return {
title: extractHtmlTitle(html),
hasShellMarker: html.includes('name="hypertwist-site-shell"') || html.includes("name='hypertwist-site-shell'"),
hasRootMount: /id=["']root["']/i.test(html),
placeholderDetected: detectPlaceholderRolloutPage(html),
bodySnippet: summarizeResponseBody(html),
}
}
export function buildRuntimeReadinessReport({
frontendEnv,
serverEnv,
liveHealth,
liveReleaseManifest,
liveWebsiteShell,
liveHealthAttempted = false,
liveReleaseManifestAttempted = false,
liveWebsiteShellAttempted = false,
frontendEnvPath = '',
serverEnvPath = '',
healthBaseUrl = '',
deploymentTier = '',
}) {
const resolvedDeploymentTier = normalizeDeploymentTier(
deploymentTier
|| frontendEnv.VITE_PUBLIC_DEPLOYMENT_TIER
|| serverEnv.DEPLOYMENT_TIER,
'launch',
)
const frontend = evaluateFrontendConfig(frontendEnv, serverEnv, resolvedDeploymentTier)
const server = evaluateServerConfig(serverEnv, resolvedDeploymentTier)
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' && liveHealth.runtime?.mode !== 'mixed') {
failures.push(`Live auth health reports runtime mode '${liveHealth.runtime?.mode || 'unknown'}' instead of 'public' or 'mixed'.`)
}
for (const message of liveHealth.runtime?.errors || []) {
failures.push(`Live runtime error: ${message}`)
}
for (const message of liveHealth.runtime?.warnings || []) {
warnings.push(`Live runtime warning: ${message}`)
}
if (liveHealth.billing?.webhookSecretConfigured !== true) {
if (resolvedDeploymentTier === 'preview') {
warnings.push('Live auth health reports Paddle webhook secret missing; billing webhook handling remains in preview posture.')
} else {
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 if (!liveHealthAttempted) {
warnings.push('Live auth health was not checked; runtime readiness was evaluated from env posture only.')
}
if (liveReleaseManifest) {
if (liveReleaseManifest.ok !== true) {
failures.push('Live release manifest did not report ok=true.')
}
if (!Array.isArray(liveReleaseManifest.manifest?.platforms)) {
failures.push('Live release manifest did not return a platforms array.')
} else {
const windowsPlatform = liveReleaseManifest.manifest.platforms.find((platform) => platform?.platform_key === 'windows')
if (!windowsPlatform) {
failures.push('Live release manifest is missing the Windows platform entry.')
}
if (liveReleaseManifest.manifest.platforms.some((platform) => normalizeTrimmed(platform?.download_url))) {
failures.push('Live public release manifest exposes a raw download URL to anonymous viewers.')
}
}
if (liveReleaseManifest.manifest?.viewer?.authenticated !== false || liveReleaseManifest.manifest?.viewer?.canDownload !== false) {
failures.push('Live public release manifest viewer posture is not anonymous/non-downloadable.')
}
} else if (!liveReleaseManifestAttempted) {
warnings.push('Live release manifest was not checked; public download-lane runtime posture was evaluated from env posture only.')
}
if (liveWebsiteShell) {
if (liveWebsiteShell.placeholderDetected) {
failures.push('Live website root still serves the placeholder rollout page instead of the first-party HyperTwist shell.')
}
if (!liveWebsiteShell.hasShellMarker) {
failures.push('Live website root is missing the first-party HyperTwist shell marker.')
}
if (!liveWebsiteShell.hasRootMount) {
failures.push('Live website root is missing the expected #root app mount.')
}
} else if (!liveWebsiteShellAttempted) {
warnings.push('Live website shell was not checked; placeholder-versus-real-site posture was not verified.')
}
return {
ok: failures.length === 0,
failures,
warnings,
frontendEnvPath,
serverEnvPath,
healthBaseUrl,
deploymentTier: resolvedDeploymentTier,
liveHealthChecked: Boolean(liveHealth),
liveReleaseManifestChecked: Boolean(liveReleaseManifest),
liveWebsiteShellChecked: Boolean(liveWebsiteShell),
}
}
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(`Deployment tier: ${report.deploymentTier || 'launch'}`)
lines.push(`Live health checked: ${report.liveHealthChecked ? 'yes' : 'no'}`)
lines.push(`Live release manifest checked: ${report.liveReleaseManifestChecked ? 'yes' : 'no'}`)
lines.push(`Live website shell checked: ${report.liveWebsiteShellChecked ? '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`
}