diff --git a/website/deploy/README.md b/website/deploy/README.md index 5967691..8da7a21 100644 --- a/website/deploy/README.md +++ b/website/deploy/README.md @@ -37,7 +37,8 @@ Before using these templates: from the local repo to stage the current committed `website/` tree into a VPS temp checkout, boot it on the host-safe staging port, verify the real `/health`, `/api/auth/health`, `/api/releases/manifest`, and root shell - surfaces, and clean the temp lane back up on success; add + surfaces, fail if the live anonymous release-manifest drifted from the + rendered launch authority, and clean the temp lane back up on success; add `--archive-source worktree` when the proof should use the in-progress local worktree rather than committed `HEAD` - after staging proof or for direct root-owned rollout, you can now also run diff --git a/website/scripts/runtime-readiness-lib.mjs b/website/scripts/runtime-readiness-lib.mjs index 4307ea7..04fe088 100644 --- a/website/scripts/runtime-readiness-lib.mjs +++ b/website/scripts/runtime-readiness-lib.mjs @@ -69,6 +69,27 @@ function isHttpsUrl(url) { return url?.protocol === 'https:' } +const RELEASE_MANIFEST_PLATFORM_EXPECTATIONS = [ + { + platformKey: 'windows', + envPrefix: 'WINDOWS', + legacyFrontendUrlKey: 'VITE_WINDOWS_DOWNLOAD_URL', + label: 'Windows', + }, + { + platformKey: 'macos', + envPrefix: 'MACOS', + legacyFrontendUrlKey: 'VITE_MAC_DOWNLOAD_URL', + label: 'macOS', + }, + { + platformKey: 'linux', + envPrefix: 'LINUX', + legacyFrontendUrlKey: 'VITE_LINUX_DOWNLOAD_URL', + label: 'Linux', + }, +] + function createBucket() { return { failures: [], @@ -202,6 +223,189 @@ function resolveEnvCandidate(frontendEnv, serverEnv, frontendKey, serverKey) { } } +function readEnvFirst(frontendEnv, serverEnv, frontendKey, serverKey = frontendKey) { + return normalizeTrimmed(frontendEnv[frontendKey] || serverEnv[serverKey]) +} + +function readServerOnly(serverEnv, key) { + return normalizeTrimmed(serverEnv[key]) +} + +function readOptionalInteger(serverEnv, key) { + const rawValue = readServerOnly(serverEnv, key) + if (!rawValue) { + return null + } + + const value = Number(rawValue) + if (!Number.isFinite(value) || value < 0) { + return null + } + + return Math.trunc(value) +} + +function formatRuntimeComparableValue(value) { + if (value === null || value === undefined) { + return '(blank)' + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false' + } + + if (typeof value === 'number') { + return Number.isFinite(value) ? String(value) : '(blank)' + } + + const trimmed = normalizeTrimmed(value) + return trimmed || '(blank)' +} + +function compareRuntimeComparableValues(expected, actual) { + return formatRuntimeComparableValue(expected) === formatRuntimeComparableValue(actual) +} + +function pushLiveManifestDriftFailure(failures, label, expected, actual) { + failures.push( + `${label} drifted from configured launch authority (expected ${formatRuntimeComparableValue(expected)}, got ${formatRuntimeComparableValue(actual)}).`, + ) +} + +function appendLiveReleaseManifestDriftChecks({ + failures, + frontendEnv, + serverEnv, + liveReleaseManifest, +}) { + const manifest = liveReleaseManifest?.manifest + if (!manifest) { + return + } + + const topLevelExpectations = [ + { + label: 'Live release manifest support email', + expected: readEnvFirst(frontendEnv, serverEnv, 'VITE_SUPPORT_EMAIL', 'SUPPORT_EMAIL') || 'hello@hypertwist.app', + actual: manifest.support_email, + }, + { + label: 'Live release manifest public docs URL', + expected: readEnvFirst(frontendEnv, serverEnv, 'VITE_PUBLIC_DOCS_URL', 'PUBLIC_DOCS_URL'), + actual: manifest.public_docs_url, + }, + { + label: 'Live release manifest release-notes URL', + expected: readEnvFirst(frontendEnv, serverEnv, 'VITE_RELEASE_NOTES_URL', 'RELEASE_NOTES_URL'), + actual: manifest.release_notes_url, + }, + { + label: 'Live release manifest corresponding-source URL', + expected: readEnvFirst(frontendEnv, serverEnv, 'VITE_MPL_SOURCE_URL', 'MPL_SOURCE_URL'), + actual: manifest.corresponding_source_url, + }, + { + label: 'Live release manifest open-source repo URL', + expected: readEnvFirst(frontendEnv, serverEnv, 'VITE_OPEN_SOURCE_REPO_URL', 'OPEN_SOURCE_REPO_URL'), + actual: manifest.open_source_repo_url, + }, + { + label: 'Live release manifest operator checkout URL', + expected: readEnvFirst(frontendEnv, serverEnv, 'VITE_PADDLE_CHECKOUT_URL_OPERATOR'), + actual: manifest.commerce?.operator_checkout_url, + }, + { + label: 'Live release manifest studio checkout URL', + expected: readEnvFirst(frontendEnv, serverEnv, 'VITE_PADDLE_CHECKOUT_URL_STUDIO'), + actual: manifest.commerce?.studio_checkout_url, + }, + { + label: 'Live release manifest operator price string', + expected: readEnvFirst(frontendEnv, serverEnv, 'VITE_PLAN_PRICE_OPERATOR') || 'Launch pricing via Paddle', + actual: manifest.commerce?.plan_price_operator, + }, + { + label: 'Live release manifest studio price string', + expected: readEnvFirst(frontendEnv, serverEnv, 'VITE_PLAN_PRICE_STUDIO') || 'Contact for launch readiness', + actual: manifest.commerce?.plan_price_studio, + }, + ] + + for (const expectation of topLevelExpectations) { + if (!compareRuntimeComparableValues(expectation.expected, expectation.actual)) { + pushLiveManifestDriftFailure(failures, expectation.label, expectation.expected, expectation.actual) + } + } + + const platforms = Array.isArray(manifest.platforms) ? manifest.platforms : [] + const globalVersion = readServerOnly(serverEnv, 'RELEASE_MANIFEST_VERSION') + const globalChannel = readServerOnly(serverEnv, 'RELEASE_MANIFEST_CHANNEL') || 'preview' + + for (const definition of RELEASE_MANIFEST_PLATFORM_EXPECTATIONS) { + const platform = platforms.find((entry) => entry?.platform_key === definition.platformKey) + if (!platform) { + failures.push(`Live release manifest is missing the ${definition.label} platform entry.`) + continue + } + + const configuredCandidate = resolveEnvCandidate( + frontendEnv, + serverEnv, + definition.legacyFrontendUrlKey, + `${definition.envPrefix}_DOWNLOAD_URL`, + ) + const expectedConfigured = Boolean(normalizeTrimmed(configuredCandidate.value)) + const platformExpectations = [ + { + label: `Live release manifest ${definition.label} configured posture`, + expected: expectedConfigured, + actual: platform.configured === true, + }, + { + label: `Live release manifest ${definition.label} release channel`, + expected: readServerOnly(serverEnv, `${definition.envPrefix}_RELEASE_CHANNEL`) || globalChannel, + actual: platform.channel, + }, + { + label: `Live release manifest ${definition.label} release version`, + expected: readServerOnly(serverEnv, `${definition.envPrefix}_RELEASE_VERSION`) || globalVersion, + actual: platform.version, + }, + { + label: `Live release manifest ${definition.label} release build ID`, + expected: readServerOnly(serverEnv, `${definition.envPrefix}_RELEASE_BUILD_ID`), + actual: platform.build_id, + }, + { + label: `Live release manifest ${definition.label} published-at timestamp`, + expected: readServerOnly(serverEnv, `${definition.envPrefix}_RELEASE_PUBLISHED_AT`), + actual: platform.published_at, + }, + { + label: `Live release manifest ${definition.label} release file name`, + expected: readServerOnly(serverEnv, `${definition.envPrefix}_RELEASE_FILE_NAME`), + actual: platform.file_name, + }, + { + label: `Live release manifest ${definition.label} release file size`, + expected: readOptionalInteger(serverEnv, `${definition.envPrefix}_RELEASE_FILE_SIZE_BYTES`), + actual: platform.file_size_bytes, + }, + { + label: `Live release manifest ${definition.label} release checksum`, + expected: readServerOnly(serverEnv, `${definition.envPrefix}_RELEASE_SHA256`), + actual: platform.checksum_sha256, + }, + ] + + for (const expectation of platformExpectations) { + if (!compareRuntimeComparableValues(expectation.expected, expectation.actual)) { + pushLiveManifestDriftFailure(failures, expectation.label, expectation.expected, expectation.actual) + } + } + } +} + function requireAbsoluteUrlCandidate(bucket, label, value) { if (!requireNonEmpty(bucket, label, value)) { return null @@ -571,6 +775,13 @@ export function buildRuntimeReadinessReport({ if (liveReleaseManifest.manifest?.viewer?.authenticated !== false || liveReleaseManifest.manifest?.viewer?.canDownload !== false) { failures.push('Live public release manifest viewer posture is not anonymous/non-downloadable.') } + + appendLiveReleaseManifestDriftChecks({ + failures, + frontendEnv, + serverEnv, + liveReleaseManifest, + }) } else if (!liveReleaseManifestAttempted) { warnings.push('Live release manifest was not checked; public download-lane runtime posture was evaluated from env posture only.') } diff --git a/website/scripts/runtime-readiness-lib.test.mjs b/website/scripts/runtime-readiness-lib.test.mjs index 189a14f..e5e3cc0 100644 --- a/website/scripts/runtime-readiness-lib.test.mjs +++ b/website/scripts/runtime-readiness-lib.test.mjs @@ -416,6 +416,126 @@ describe('buildRuntimeReadinessReport', () => { expect(report.failures).toContain('Live website root is missing the first-party HyperTwist shell marker.') expect(report.failures).toContain('Live website root is missing the expected #root app mount.') }) + + it('fails when the live release manifest drifts from the configured launch authority', () => { + const report = buildRuntimeReadinessReport({ + frontendEnv: { + VITE_SUPPORT_EMAIL: 'ops@hypertwist.app', + VITE_PUBLIC_DOCS_URL: 'https://docs.hypertwist.app', + VITE_RELEASE_NOTES_URL: 'https://notes.hypertwist.app', + VITE_SUPERTOKENS_API_DOMAIN: 'https://hypertwist.app', + VITE_SUPERTOKENS_WEBSITE_DOMAIN: 'https://hypertwist.app', + VITE_AUTH_API_BASE_URL: 'https://hypertwist.app', + VITE_PADDLE_CHECKOUT_URL_OPERATOR: 'https://buy.paddle.com/operator-live', + VITE_PADDLE_CHECKOUT_URL_STUDIO: 'https://buy.paddle.com/studio-live', + VITE_PLAN_PRICE_OPERATOR: '$19 / month', + VITE_PLAN_PRICE_STUDIO: '$99 / month', + VITE_MPL_SOURCE_URL: 'https://hypertwist.app/open-source/source.zip', + VITE_OPEN_SOURCE_REPO_URL: 'https://git.scriptoriumai.io/scriptoriumadmin/hypertwist', + VITE_WINDOWS_DOWNLOAD_URL: 'https://downloads.hypertwist.app/windows.exe', + }, + 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"}', + RELEASE_MANIFEST_VERSION: '1.0.0', + RELEASE_MANIFEST_CHANNEL: 'candidate', + WINDOWS_RELEASE_BUILD_ID: 'win64-1000', + WINDOWS_RELEASE_SHA256: 'abc123', + WINDOWS_RELEASE_FILE_SIZE_BYTES: '1048576', + }, + liveHealth: { + supertokens: { ready: true }, + fallback: { active: false }, + runtime: { + public_origin_ready: true, + mode: 'public', + errors: [], + warnings: [], + }, + billing: { + webhookSecretConfigured: true, + productPlanMapConfigured: false, + pricePlanMapConfigured: true, + }, + }, + liveReleaseManifest: { + ok: true, + manifest: { + support_email: 'hello@hypertwist.app', + public_docs_url: 'https://old-docs.hypertwist.app', + release_notes_url: 'https://notes.hypertwist.app', + corresponding_source_url: 'https://hypertwist.app/open-source/source.zip', + open_source_repo_url: 'https://git.scriptoriumai.io/scriptoriumadmin/hypertwist', + commerce: { + operator_checkout_url: 'https://buy.paddle.com/operator-old', + studio_checkout_url: 'https://buy.paddle.com/studio-live', + plan_price_operator: '$29 / month', + plan_price_studio: '$99 / month', + }, + viewer: { + authenticated: false, + canDownload: false, + }, + platforms: [ + { + platform_key: 'windows', + configured: false, + channel: 'preview', + version: '0.9.0', + build_id: 'old-build', + published_at: null, + file_name: null, + file_size_bytes: 512, + checksum_sha256: 'stale', + download_url: null, + }, + { + platform_key: 'macos', + configured: false, + channel: 'preview', + version: null, + build_id: null, + published_at: null, + file_name: null, + file_size_bytes: null, + checksum_sha256: null, + download_url: null, + }, + { + platform_key: 'linux', + configured: false, + channel: 'preview', + version: null, + build_id: null, + published_at: null, + file_name: null, + file_size_bytes: null, + checksum_sha256: null, + download_url: null, + }, + ], + }, + }, + liveReleaseManifestAttempted: true, + liveHealthAttempted: true, + }) + + expect(report.ok).toBe(false) + expect(report.failures).toContain('Live release manifest support email drifted from configured launch authority (expected ops@hypertwist.app, got hello@hypertwist.app).') + expect(report.failures).toContain('Live release manifest public docs URL drifted from configured launch authority (expected https://docs.hypertwist.app, got https://old-docs.hypertwist.app).') + expect(report.failures).toContain('Live release manifest operator checkout URL drifted from configured launch authority (expected https://buy.paddle.com/operator-live, got https://buy.paddle.com/operator-old).') + expect(report.failures).toContain('Live release manifest operator price string drifted from configured launch authority (expected $19 / month, got $29 / month).') + expect(report.failures).toContain('Live release manifest Windows configured posture drifted from configured launch authority (expected true, got false).') + expect(report.failures).toContain('Live release manifest Windows release channel drifted from configured launch authority (expected candidate, got preview).') + expect(report.failures).toContain('Live release manifest Windows release version drifted from configured launch authority (expected 1.0.0, got 0.9.0).') + expect(report.failures).toContain('Live release manifest Windows release build ID drifted from configured launch authority (expected win64-1000, got old-build).') + expect(report.failures).toContain('Live release manifest Windows release file size drifted from configured launch authority (expected 1048576, got 512).') + expect(report.failures).toContain('Live release manifest Windows release checksum drifted from configured launch authority (expected abc123, got stale).') + }) }) describe('deriveHealthBaseUrl', () => { diff --git a/website/server/README.md b/website/server/README.md index 02b97c5..b02d900 100644 --- a/website/server/README.md +++ b/website/server/README.md @@ -178,6 +178,12 @@ That means the verifier can now fail explicitly when `hypertwist.app` is still serving the earlier placeholder rollout page instead of the real same-origin website/auth-server deployment. +The same verifier now also compares the live anonymous release-manifest payload +against the currently configured launch authority for support/docs/source, +checkout/price, and per-platform release metadata, so a stale host env or +partially rolled deploy fails as configuration drift instead of passing on +structural safety alone. + Live same-origin status on `2026-06-22`: - the root-owned cutover now has `https://hypertwist.app` serving the