337 lines
13 KiB
JavaScript
337 lines
13 KiB
JavaScript
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
|
|
import { buildRuntimeReadinessReport, formatRuntimeReadinessReport } from './runtime-readiness-lib.mjs'
|
|
import {
|
|
renderNginxConfig,
|
|
renderSystemdService,
|
|
resolveDeploymentRenderOptions,
|
|
} from './render-same-origin-deployment-lib.mjs'
|
|
|
|
function normalizeTrimmed(value) {
|
|
return String(value || '').trim()
|
|
}
|
|
|
|
function normalizeBoolean(value, fallback = false) {
|
|
if (typeof value === 'boolean') {
|
|
return value
|
|
}
|
|
const normalized = normalizeTrimmed(value).toLowerCase()
|
|
if (!normalized) {
|
|
return fallback
|
|
}
|
|
return normalized === 'true' || normalized === '1' || normalized === 'yes'
|
|
}
|
|
|
|
function normalizeServerNames(serverNames) {
|
|
const names = Array.isArray(serverNames)
|
|
? serverNames
|
|
: String(serverNames || '').split(',')
|
|
|
|
return names
|
|
.map((value) => normalizeTrimmed(value))
|
|
.filter(Boolean)
|
|
}
|
|
|
|
function requireNonEmpty(value, label) {
|
|
const normalized = normalizeTrimmed(value)
|
|
if (!normalized) {
|
|
throw new Error(`${label} is required.`)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
function resolvePublicOrigin(manifest = {}, serverNames = []) {
|
|
const explicitOrigin = normalizeTrimmed(manifest.publicOrigin || '')
|
|
if (explicitOrigin) {
|
|
return requireNonEmpty(explicitOrigin, 'publicOrigin')
|
|
}
|
|
|
|
const primaryServerName = serverNames[0] || 'hypertwist.app'
|
|
return `https://${primaryServerName}`
|
|
}
|
|
|
|
function normalizeDeploymentTier(value, fallback = 'launch') {
|
|
const normalized = normalizeTrimmed(value).toLowerCase()
|
|
if (normalized === 'preview' || normalized === 'launch') {
|
|
return normalized
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
function requireNonEmptyForTier(value, label, deploymentTier) {
|
|
const normalized = normalizeTrimmed(value)
|
|
if (!normalized && deploymentTier !== 'preview') {
|
|
throw new Error(`${label} is required.`)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
function quoteEnvValue(value) {
|
|
const normalized = String(value ?? '')
|
|
if (!normalized) {
|
|
return ''
|
|
}
|
|
|
|
if (/[ \t#{}[\]"']/.test(normalized)) {
|
|
return `'${normalized.replace(/'/g, "'\\''")}'`
|
|
}
|
|
|
|
return normalized
|
|
}
|
|
|
|
function serializeEnvEntries(entries) {
|
|
return Object.entries(entries)
|
|
.map(([key, value]) => `${key}=${quoteEnvValue(value)}`)
|
|
.join('\n')
|
|
.concat('\n')
|
|
}
|
|
|
|
export function loadBundleManifest(filePath) {
|
|
const absolutePath = path.resolve(filePath)
|
|
const raw = fs.readFileSync(absolutePath, 'utf8')
|
|
return {
|
|
path: absolutePath,
|
|
manifest: JSON.parse(raw),
|
|
}
|
|
}
|
|
|
|
export function resolveBundleManifest(manifest = {}) {
|
|
const server = manifest.server || {}
|
|
const windowsRelease = manifest.windowsRelease || {}
|
|
const serviceUser = normalizeTrimmed(manifest.serviceUser || 'hypertwist')
|
|
const serviceGroup = normalizeTrimmed(manifest.serviceGroup || serviceUser)
|
|
const serverNames = normalizeServerNames(manifest.serverNames)
|
|
const deploymentTier = normalizeDeploymentTier(manifest.deploymentTier || server.deploymentTier, 'launch')
|
|
|
|
return {
|
|
checkoutRoot: requireNonEmpty(manifest.checkoutRoot, 'checkoutRoot'),
|
|
serviceUser,
|
|
serviceGroup,
|
|
deploymentTier,
|
|
publicOrigin: resolvePublicOrigin(manifest, serverNames),
|
|
serverNames: serverNames.length > 0 ? serverNames : undefined,
|
|
certificateName: normalizeTrimmed(manifest.certificateName || ''),
|
|
supportEmail: requireNonEmpty(manifest.supportEmail, 'supportEmail'),
|
|
publicDocsUrl: requireNonEmpty(manifest.publicDocsUrl, 'publicDocsUrl'),
|
|
releaseNotesUrl: requireNonEmpty(manifest.releaseNotesUrl, 'releaseNotesUrl'),
|
|
correspondingSourceUrl: requireNonEmpty(manifest.correspondingSourceUrl, 'correspondingSourceUrl'),
|
|
openSourceRepoUrl: requireNonEmpty(manifest.openSourceRepoUrl, 'openSourceRepoUrl'),
|
|
operatorCheckoutUrl: requireNonEmptyForTier(manifest.operatorCheckoutUrl, 'operatorCheckoutUrl', deploymentTier),
|
|
studioCheckoutUrl: normalizeTrimmed(manifest.studioCheckoutUrl || ''),
|
|
planPriceOperator: normalizeTrimmed(manifest.planPriceOperator || 'Launch pricing via Paddle'),
|
|
planPriceStudio: normalizeTrimmed(manifest.planPriceStudio || 'Contact for launch readiness'),
|
|
windowsDownloadUrl: requireNonEmptyForTier(manifest.windowsDownloadUrl, 'windowsDownloadUrl', deploymentTier),
|
|
releaseManifestVersion: requireNonEmptyForTier(manifest.releaseManifestVersion, 'releaseManifestVersion', deploymentTier),
|
|
releaseManifestChannel: normalizeTrimmed(manifest.releaseManifestChannel || 'preview'),
|
|
windowsRelease: {
|
|
buildId: requireNonEmptyForTier(windowsRelease.buildId, 'windowsRelease.buildId', deploymentTier),
|
|
sha256: requireNonEmptyForTier(windowsRelease.sha256, 'windowsRelease.sha256', deploymentTier),
|
|
fileSizeBytes: String(windowsRelease.fileSizeBytes ?? ''),
|
|
publishedAt: requireNonEmptyForTier(windowsRelease.publishedAt, 'windowsRelease.publishedAt', deploymentTier),
|
|
},
|
|
server: {
|
|
port: normalizeTrimmed(server.port || '3011'),
|
|
deploymentTier,
|
|
superTokensCoreUri: normalizeTrimmed(server.superTokensCoreUri || 'http://127.0.0.1:3567'),
|
|
apiBasePath: normalizeTrimmed(server.apiBasePath || '/auth'),
|
|
websiteBasePath: normalizeTrimmed(server.websiteBasePath || '/auth'),
|
|
cookieSecure: normalizeBoolean(server.cookieSecure, true),
|
|
serveStaticWebsite: normalizeBoolean(server.serveStaticWebsite, true),
|
|
websiteDistPath: normalizeTrimmed(server.websiteDistPath || ''),
|
|
githubClientId: normalizeTrimmed(server.githubClientId || ''),
|
|
githubClientSecret: normalizeTrimmed(server.githubClientSecret || ''),
|
|
googleClientId: normalizeTrimmed(server.googleClientId || ''),
|
|
googleClientSecret: normalizeTrimmed(server.googleClientSecret || ''),
|
|
defaultPlan: normalizeTrimmed(server.defaultPlan || 'free'),
|
|
defaultRole: normalizeTrimmed(server.defaultRole || 'operator'),
|
|
healthTimeoutMs: normalizeTrimmed(server.healthTimeoutMs || '2000'),
|
|
desktopLinkRateLimitMax: normalizeTrimmed(server.desktopLinkRateLimitMax || '5'),
|
|
desktopLinkRateLimitWindowMs: normalizeTrimmed(server.desktopLinkRateLimitWindowMs || '900000'),
|
|
paddleWebhookSecret: requireNonEmptyForTier(server.paddleWebhookSecret, 'server.paddleWebhookSecret', deploymentTier),
|
|
paddleWebhookToleranceMs: normalizeTrimmed(server.paddleWebhookToleranceMs || '5000'),
|
|
billingStatePath: normalizeTrimmed(server.billingStatePath || '/var/lib/hypertwist/auth/hypertwist-billing-state.json'),
|
|
paddleProductPlanMap: server.paddleProductPlanMap || {},
|
|
paddlePricePlanMap: server.paddlePricePlanMap || {},
|
|
},
|
|
}
|
|
}
|
|
|
|
export function buildFrontendEnvEntries(manifest = {}) {
|
|
const resolved = resolveBundleManifest(manifest)
|
|
|
|
return {
|
|
VITE_PUBLIC_DEPLOYMENT_TIER: resolved.deploymentTier,
|
|
VITE_SUPERTOKENS_API_DOMAIN: resolved.publicOrigin,
|
|
VITE_SUPERTOKENS_WEBSITE_DOMAIN: resolved.publicOrigin,
|
|
VITE_SUPERTOKENS_API_BASE_PATH: resolved.server.apiBasePath,
|
|
VITE_SUPERTOKENS_WEBSITE_BASE_PATH: resolved.server.websiteBasePath,
|
|
VITE_AUTH_API_BASE_URL: resolved.publicOrigin,
|
|
VITE_AUTH_API_TIMEOUT_MS: '8000',
|
|
VITE_GITHUB_OAUTH_ENABLED: String(Boolean(resolved.server.githubClientId && resolved.server.githubClientSecret)),
|
|
VITE_GOOGLE_OAUTH_ENABLED: String(Boolean(resolved.server.googleClientId && resolved.server.googleClientSecret)),
|
|
VITE_ORCID_OAUTH_ENABLED: 'false',
|
|
VITE_PUBLIC_DOCS_URL: resolved.publicDocsUrl,
|
|
VITE_RELEASE_NOTES_URL: resolved.releaseNotesUrl,
|
|
VITE_SUPPORT_EMAIL: resolved.supportEmail,
|
|
VITE_WINDOWS_DOWNLOAD_URL: resolved.windowsDownloadUrl,
|
|
VITE_MAC_DOWNLOAD_URL: '',
|
|
VITE_LINUX_DOWNLOAD_URL: '',
|
|
VITE_PADDLE_CHECKOUT_URL_OPERATOR: resolved.operatorCheckoutUrl,
|
|
VITE_PADDLE_CHECKOUT_URL_STUDIO: resolved.studioCheckoutUrl,
|
|
VITE_PLAN_PRICE_OPERATOR: resolved.planPriceOperator,
|
|
VITE_PLAN_PRICE_STUDIO: resolved.planPriceStudio,
|
|
VITE_MPL_SOURCE_URL: resolved.correspondingSourceUrl,
|
|
VITE_OPEN_SOURCE_REPO_URL: resolved.openSourceRepoUrl,
|
|
}
|
|
}
|
|
|
|
export function buildServerEnvEntries(manifest = {}) {
|
|
const resolved = resolveBundleManifest(manifest)
|
|
|
|
return {
|
|
DEPLOYMENT_TIER: resolved.deploymentTier,
|
|
PORT: resolved.server.port,
|
|
SUPERTOKENS_CORE_URI: resolved.server.superTokensCoreUri,
|
|
API_DOMAIN: resolved.publicOrigin,
|
|
WEBSITE_DOMAIN: resolved.publicOrigin,
|
|
API_BASE_PATH: resolved.server.apiBasePath,
|
|
WEBSITE_BASE_PATH: resolved.server.websiteBasePath,
|
|
COOKIE_SECURE: String(resolved.server.cookieSecure),
|
|
SERVE_STATIC_WEBSITE: String(resolved.server.serveStaticWebsite),
|
|
WEBSITE_DIST_PATH: resolved.server.websiteDistPath,
|
|
GITHUB_CLIENT_ID: resolved.server.githubClientId,
|
|
GITHUB_CLIENT_SECRET: resolved.server.githubClientSecret,
|
|
GOOGLE_CLIENT_ID: resolved.server.googleClientId,
|
|
GOOGLE_CLIENT_SECRET: resolved.server.googleClientSecret,
|
|
DEFAULT_PLAN: resolved.server.defaultPlan,
|
|
DEFAULT_ROLE: resolved.server.defaultRole,
|
|
SUPERTOKENS_HEALTH_TIMEOUT_MS: resolved.server.healthTimeoutMs,
|
|
DESKTOP_LINK_RATE_LIMIT_MAX: resolved.server.desktopLinkRateLimitMax,
|
|
DESKTOP_LINK_RATE_LIMIT_WINDOW_MS: resolved.server.desktopLinkRateLimitWindowMs,
|
|
PADDLE_WEBHOOK_SECRET: resolved.server.paddleWebhookSecret,
|
|
PADDLE_WEBHOOK_TOLERANCE_MS: resolved.server.paddleWebhookToleranceMs,
|
|
BILLING_STATE_PATH: resolved.server.billingStatePath,
|
|
PADDLE_PRODUCT_PLAN_MAP: JSON.stringify(resolved.server.paddleProductPlanMap),
|
|
PADDLE_PRICE_PLAN_MAP: JSON.stringify(resolved.server.paddlePricePlanMap),
|
|
SUPPORT_EMAIL: resolved.supportEmail,
|
|
PUBLIC_DOCS_URL: resolved.publicDocsUrl,
|
|
RELEASE_NOTES_URL: resolved.releaseNotesUrl,
|
|
MPL_SOURCE_URL: resolved.correspondingSourceUrl,
|
|
OPEN_SOURCE_REPO_URL: resolved.openSourceRepoUrl,
|
|
RELEASE_MANIFEST_VERSION: resolved.releaseManifestVersion,
|
|
RELEASE_MANIFEST_CHANNEL: resolved.releaseManifestChannel,
|
|
WINDOWS_DOWNLOAD_URL: resolved.windowsDownloadUrl,
|
|
WINDOWS_RELEASE_BUILD_ID: resolved.windowsRelease.buildId,
|
|
WINDOWS_RELEASE_SHA256: resolved.windowsRelease.sha256,
|
|
WINDOWS_RELEASE_FILE_SIZE_BYTES: resolved.windowsRelease.fileSizeBytes,
|
|
WINDOWS_RELEASE_PUBLISHED_AT: resolved.windowsRelease.publishedAt,
|
|
MACOS_DOWNLOAD_URL: '',
|
|
MACOS_RELEASE_BUILD_ID: '',
|
|
MACOS_RELEASE_SHA256: '',
|
|
MACOS_RELEASE_FILE_SIZE_BYTES: '',
|
|
MACOS_RELEASE_PUBLISHED_AT: '',
|
|
LINUX_DOWNLOAD_URL: '',
|
|
LINUX_RELEASE_BUILD_ID: '',
|
|
LINUX_RELEASE_SHA256: '',
|
|
LINUX_RELEASE_FILE_SIZE_BYTES: '',
|
|
LINUX_RELEASE_PUBLISHED_AT: '',
|
|
}
|
|
}
|
|
|
|
export function buildBundleValidationReport(manifest = {}) {
|
|
const resolved = resolveBundleManifest(manifest)
|
|
const frontendEnv = buildFrontendEnvEntries(resolved)
|
|
const serverEnv = buildServerEnvEntries(resolved)
|
|
|
|
return buildRuntimeReadinessReport({
|
|
frontendEnv,
|
|
serverEnv,
|
|
deploymentTier: resolved.deploymentTier,
|
|
liveHealth: null,
|
|
liveHealthAttempted: false,
|
|
liveReleaseManifest: null,
|
|
liveReleaseManifestAttempted: false,
|
|
liveWebsiteShell: null,
|
|
liveWebsiteShellAttempted: false,
|
|
})
|
|
}
|
|
|
|
export function renderSameOriginBundle(manifest = {}) {
|
|
const resolved = resolveBundleManifest(manifest)
|
|
const frontendEnv = buildFrontendEnvEntries(resolved)
|
|
const serverEnv = buildServerEnvEntries(resolved)
|
|
const deploymentOptions = resolveDeploymentRenderOptions({
|
|
checkoutRoot: resolved.checkoutRoot,
|
|
serviceUser: resolved.serviceUser,
|
|
serviceGroup: resolved.serviceGroup,
|
|
serverNames: resolved.serverNames,
|
|
certificateName: resolved.certificateName,
|
|
upstreamPort: resolved.server.port,
|
|
})
|
|
|
|
const validationReport = buildRuntimeReadinessReport({
|
|
frontendEnv,
|
|
serverEnv,
|
|
deploymentTier: resolved.deploymentTier,
|
|
liveHealth: null,
|
|
liveHealthAttempted: false,
|
|
liveReleaseManifest: null,
|
|
liveReleaseManifestAttempted: false,
|
|
liveWebsiteShell: null,
|
|
liveWebsiteShellAttempted: false,
|
|
})
|
|
|
|
if (!validationReport.ok) {
|
|
throw new Error(`Rendered deployment bundle is not runtime-ready.\n${formatRuntimeReadinessReport(validationReport)}`)
|
|
}
|
|
|
|
return {
|
|
deploymentOptions,
|
|
frontendEnv,
|
|
serverEnv,
|
|
frontendEnvContent: serializeEnvEntries(frontendEnv),
|
|
serverEnvContent: serializeEnvEntries(serverEnv),
|
|
systemdContent: renderSystemdService(deploymentOptions),
|
|
nginxContent: renderNginxConfig(deploymentOptions),
|
|
validationReport,
|
|
}
|
|
}
|
|
|
|
export function writeSameOriginBundle({
|
|
manifest,
|
|
outDir,
|
|
}) {
|
|
const rendered = renderSameOriginBundle(manifest)
|
|
const outputDir = path.resolve(outDir)
|
|
|
|
fs.mkdirSync(outputDir, { recursive: true })
|
|
|
|
const frontendEnvPath = path.join(outputDir, 'website.env')
|
|
const serverEnvPath = path.join(outputDir, 'server.env')
|
|
const systemdPath = path.join(outputDir, 'hypertwist-website-auth-server.service')
|
|
const nginxPath = path.join(outputDir, 'hypertwist.app.conf')
|
|
const summaryPath = path.join(outputDir, 'bundle-summary.json')
|
|
|
|
fs.writeFileSync(frontendEnvPath, rendered.frontendEnvContent, 'utf8')
|
|
fs.writeFileSync(serverEnvPath, rendered.serverEnvContent, 'utf8')
|
|
fs.writeFileSync(systemdPath, rendered.systemdContent, 'utf8')
|
|
fs.writeFileSync(nginxPath, rendered.nginxContent, 'utf8')
|
|
fs.writeFileSync(summaryPath, `${JSON.stringify({
|
|
frontendEnvPath,
|
|
serverEnvPath,
|
|
systemdPath,
|
|
nginxPath,
|
|
validationReport: rendered.validationReport,
|
|
}, null, 2)}\n`, 'utf8')
|
|
|
|
return {
|
|
...rendered,
|
|
outputDir,
|
|
frontendEnvPath,
|
|
serverEnvPath,
|
|
systemdPath,
|
|
nginxPath,
|
|
summaryPath,
|
|
}
|
|
}
|