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 || ''), orcidClientId: normalizeTrimmed(server.orcidClientId || ''), orcidClientSecret: normalizeTrimmed(server.orcidClientSecret || ''), orcidIsSandbox: normalizeBoolean(server.orcidIsSandbox, false), 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: String(Boolean(resolved.server.orcidClientId && resolved.server.orcidClientSecret)), 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, ORCID_CLIENT_ID: resolved.server.orcidClientId, ORCID_CLIENT_SECRET: resolved.server.orcidClientSecret, ORCID_IS_SANDBOX: String(resolved.server.orcidIsSandbox), 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, } }