423 lines
13 KiB
JavaScript
423 lines
13 KiB
JavaScript
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 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 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 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.')
|
|
} else {
|
|
rejectPlaceholderValue(bucket, 'VITE_WINDOWS_DOWNLOAD_URL', frontendEnv.VITE_WINDOWS_DOWNLOAD_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.')
|
|
} else {
|
|
rejectPlaceholderValue(bucket, 'VITE_PADDLE_CHECKOUT_URL_OPERATOR', frontendEnv.VITE_PADDLE_CHECKOUT_URL_OPERATOR)
|
|
}
|
|
|
|
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.')
|
|
} else {
|
|
rejectPlaceholderValue(bucket, 'VITE_MPL_SOURCE_URL', frontendEnv.VITE_MPL_SOURCE_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.')
|
|
} else {
|
|
rejectPlaceholderValue(bucket, 'VITE_OPEN_SOURCE_REPO_URL', frontendEnv.VITE_OPEN_SOURCE_REPO_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.')
|
|
} 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) {
|
|
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.')
|
|
} 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.')
|
|
}
|
|
|
|
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) {
|
|
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`
|
|
}
|