Add website deployment readiness check
This commit is contained in:
parent
126768ee3a
commit
576de0df1c
10 changed files with 628 additions and 3 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
102
website/scripts/check-runtime-readiness.mjs
Normal file
102
website/scripts/check-runtime-readiness.mjs
Normal file
|
|
@ -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
|
||||
381
website/scripts/runtime-readiness-lib.mjs
Normal file
381
website/scripts/runtime-readiness-lib.mjs
Normal file
|
|
@ -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`
|
||||
}
|
||||
105
website/scripts/runtime-readiness-lib.test.mjs
Normal file
105
website/scripts/runtime-readiness-lib.test.mjs
Normal file
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue