From e4fcff160b9b51af471f3cd2b71d71b27929265f Mon Sep 17 00:00:00 2001 From: axiomlogicnexus Date: Mon, 22 Jun 2026 03:34:04 +0000 Subject: [PATCH] Prove website production deployment posture --- ...LING_AND_DISTRIBUTION_PACKET_2026-06-22.md | 14 + ..._RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md | 8 + .../HyperTwist/FEATURE_REGISTRY.md | 6 +- .../HyperTwist/ROADMAP.md | 9 +- website/README.md | 6 + .../scripts/runtime-readiness-cli.test.mjs | 44 +++ website/server/README.md | 5 + .../src/__tests__/server-bootstrap.test.ts | 298 ++++++++++++++++++ .../__tests__/download-center-page.test.tsx | 80 +++++ .../src/__tests__/public-auth-pages.test.tsx | 5 +- website/src/__tests__/public-launch.test.ts | 50 +++ .../__tests__/public-marketing-pages.test.tsx | 132 ++++++++ website/src/__tests__/site-routes.test.ts | 28 ++ .../src/components/ui/PublicLaunchStatus.tsx | 48 +++ website/src/pages/app-pages.tsx | 41 ++- website/src/pages/public-pages.tsx | 66 +++- website/src/public-launch.ts | 64 ++++ website/src/site-config.ts | 20 +- website/src/site-routes.ts | 48 +++ website/src/styles/global.css | 26 ++ 20 files changed, 983 insertions(+), 15 deletions(-) create mode 100644 website/scripts/runtime-readiness-cli.test.mjs create mode 100644 website/server/src/__tests__/server-bootstrap.test.ts create mode 100644 website/src/__tests__/download-center-page.test.tsx create mode 100644 website/src/__tests__/public-launch.test.ts create mode 100644 website/src/__tests__/public-marketing-pages.test.tsx create mode 100644 website/src/__tests__/site-routes.test.ts create mode 100644 website/src/components/ui/PublicLaunchStatus.tsx create mode 100644 website/src/public-launch.ts create mode 100644 website/src/site-routes.ts diff --git a/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_AUTH_BILLING_AND_DISTRIBUTION_PACKET_2026-06-22.md b/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_AUTH_BILLING_AND_DISTRIBUTION_PACKET_2026-06-22.md index d729246..8f0d27e 100644 --- a/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_AUTH_BILLING_AND_DISTRIBUTION_PACKET_2026-06-22.md +++ b/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_AUTH_BILLING_AND_DISTRIBUTION_PACKET_2026-06-22.md @@ -121,6 +121,12 @@ The protected dashboard now also has a first-party launch-readiness panel for: - backend webhook-secret and billing-map configuration posture - public auth runtime posture, cookie-hardening posture, and local-vs-public runtime diagnostics +The public pricing, download, and notices routes now also mirror that same +bounded launch-truth posture through first-party preview-versus-launch callouts +so external-facing copy no longer depends on operators opening the protected +dashboard to see whether checkout, download, or notice configuration is still +missing. + 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. @@ -154,11 +160,15 @@ The frontend behavior coverage now also explicitly pins: - safe `next`-path normalization across custom auth pages and SuperTokens post-auth redirect handoff - browser auth-bootstrap normalization when the account payload reports email/fallback posture - login/register page continuation behavior plus the public `/download` page rule that configured release targets still route through the protected dashboard instead of exposing raw URLs +- target-preserving public `/download -> /app/downloads?platform=...` continuation plus protected requested-target surfacing after auth handoff +- pricing and launch-status support actions routing into explicit support topics instead of dead-end same-page fallback CTAs when live checkout is not configured yet - protected app-route loading/redirect behavior plus auth-aware marketing/app shell actions - real `AppRouteTree` smoke coverage for homepage, pricing, download, login, dashboard, and dashboard-download routes through the lazy public/protected router itself - top-level `App` bootstrap coverage for unknown-route redirect plus SuperTokens wrapper enabled-versus-fallback posture - login/register unhappy-path coverage for returned form errors, auth-runtime warning callouts, and OAuth-button visibility/invocation - dashboard launch-readiness visibility plus generated desktop-link verify URL behavior +- real `check-runtime-readiness` CLI proof against the checked-in `.env.production.example` files so placeholder launch scaffolds still fail the documented deploy-time command +- spawned `website/server` bootstrap proof from production-shaped same-origin env into live `/health`, `/api/auth/health`, and same-origin built-shell delivery The first-party auth server now also supports bounded same-origin public serving for `hypertwist.app` when a built `website/dist/index.html` is present: @@ -181,6 +191,7 @@ The website now includes: - plan-gated release posture - browser-to-desktop token handoff - protected-dashboard download gating above the public target-description page +- target-aware handoff so public download selections survive the protected auth boundary Current pairing endpoint family: @@ -194,6 +205,7 @@ Current hardening on that lane now also includes: - one-time verification-token consumption after the first successful desktop-side use - billing-resolved plan/role plus download-entitlement posture carried into the desktop-link token rather than stale session defaults - public `/download` messaging that routes real download access through the protected dashboard instead of exposing raw release URLs on the marketing page +- requested platform continuity from the marketing page into the protected download surface ### Billing posture @@ -265,6 +277,8 @@ Validation result on `2026-06-22`: - backend focused security tests passed - `website/server` billing/entitlement tests passed - the new bounded auth-health probe tests passed, and the protected dashboard type/build surface remained green after adopting the richer health contract +- the spawned same-origin auth-server bootstrap proof passed +- the real runtime-readiness CLI against the checked-in production example env files failed exactly on the remaining placeholder launch values ## Explicit non-claims diff --git a/docs/ops/HYPERTWIST_WEBSITE_RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md b/docs/ops/HYPERTWIST_WEBSITE_RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md index a90d4b8..b67db0b 100644 --- a/docs/ops/HYPERTWIST_WEBSITE_RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md +++ b/docs/ops/HYPERTWIST_WEBSITE_RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md @@ -56,6 +56,11 @@ the readiness command still fails until real launch values are inserted. They now also carry the explicit static-serving control variables for the same-origin lane. +That expectation is now proven two ways in the repo itself: + +- the real `check-runtime-readiness` CLI is exercised against the checked-in production example files and must fail until placeholders are replaced +- the auth-server bootstrap lane is exercised from production-shaped same-origin env into live `/health`, `/api/auth/health`, and built-shell serving + ## Why same-origin is the clean default The current first-party browser lane already supports exact-origin checks, @@ -110,6 +115,9 @@ npm run check:runtime-readiness -- --frontend-env .env --server-env server/.env Use `--skip-live-health` when a deployed endpoint is not reachable yet and only env-file posture should be checked. +The repo now also keeps that command honest by testing it directly against the +checked-in production example files. + ## Related authorities - `website/README.md` diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md index c6eb8d0..bd6db9a 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md @@ -263,9 +263,9 @@ 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. 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, plus separated local-versus-production env templates whose placeholder values are intentionally rejected until real launch config is in place, bootstrap CI now validates both the frontend and auth-server website commands directly, and the auth server can now auto-serve the built `website/dist` bundle with bounded SPA fallback for same-origin public deployment. Request-level server coverage now also proves that public/app shell delivery does not shadow `/api/*`, `/auth*`, `/health`, or missing asset paths. | -| 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. Focused frontend coverage now also protects deep-link login redirect preservation, safe `next`-path normalization across auth entry points, fallback/email auth-bootstrap normalization, login/register continuation behavior, public download-gating behavior, protected-route/shell behavior, real lazy-route tree behavior for key public and protected paths, top-level app-bootstrap and SuperTokens-wrapper posture, login/register unhappy-path and OAuth-button behavior, and desktop-link verify-url/dashboard readiness behavior. | -| 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. | +| 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, plus separated local-versus-production env templates whose placeholder values are intentionally rejected until real launch config is in place, bootstrap CI now validates both the frontend and auth-server website commands directly, and the auth server can now auto-serve the built `website/dist` bundle with bounded SPA fallback for same-origin public deployment. Request-level server coverage now also proves that public/app shell delivery does not shadow `/api/*`, `/auth*`, `/health`, or missing asset paths, while the pricing/download/notices routes now surface first-party preview-versus-launch posture from the same bounded launch checklist instead of relying on hidden operator-only status. The real `check-runtime-readiness` CLI is now also exercised against the checked-in production example env files, and a spawned `website/server` bootstrap proof now verifies the live same-origin process path from production-shaped env into `/health`, `/api/auth/health`, and built-shell serving. | +| 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. Focused frontend coverage now also protects deep-link login redirect preservation, safe `next`-path normalization across auth entry points, fallback/email auth-bootstrap normalization, login/register continuation behavior, public download-gating behavior, protected-route/shell behavior, real lazy-route tree behavior for key public and protected paths, top-level app-bootstrap and SuperTokens-wrapper posture, login/register unhappy-path and OAuth-button behavior, support-topic fallback routing when live checkout is not configured, and desktop-link verify-url/dashboard readiness behavior. | +| 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, preserves requested platform continuity through `/app/downloads?platform=...`, and surfaces that requested target again after auth handoff inside the protected release lane. 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. | | Public open-source notices and corresponding-source surface | Implemented now | first-party `website/` app + `HYPERTWIST_MPL_DISTRIBUTION_PLACEMENT_CHECKLIST_2026-05-25.md` | HyperTwist now has a stable public `Open Source Notices` route linked from pricing, download, and footer surfaces, satisfying the requirement that public distribution surfaces expose notice and corresponding-source guidance when shipped builds contain `MPL`-covered material. The exact public corresponding-source URL still must be configured before external launch. | diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md index 895197e..ceaf734 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md @@ -228,7 +228,14 @@ Current consolidated milestone snapshot: plus top-level `App` bootstrap proof for unknown-route redirect and SuperTokens-wrapper posture, plus login/register unhappy-path proof for returned form errors, auth-runtime warning posture, and OAuth-button - behavior, + behavior, plus public launch-status callouts on pricing/download/notices, + target-preserving `/download -> /app/downloads?platform=...` continuation, + protected requested-target surfacing after auth handoff, and support-topic + fallback routing when live checkout is not configured, plus real + `check-runtime-readiness` CLI proof against the checked-in production example + env files and spawned `website/server` bootstrap proof from + production-shaped same-origin env into live `/health`, `/api/auth/health`, + and built-shell delivery, and the auth server can now auto-serve the built `website/dist` bundle with bounded SPA fallback for same-origin `hypertwist.app` deployment when that build output is present, while the env templates and diff --git a/website/README.md b/website/README.md index 0748aa4..9626591 100644 --- a/website/README.md +++ b/website/README.md @@ -8,6 +8,7 @@ First-party `hypertwist.app` surface for HyperTwist: - desktop download posture and desktop-link handshake endpoints - Paddle-ready pricing/check-out wiring - dashboard-side launch-readiness surface for download, checkout, auth, and notice configuration +- public launch-status callouts across pricing, download, and notices surfaces - runtime diagnostics that distinguish local, mixed, and public auth deployment posture - public open-source notices surface required by HyperTwist's MPL distribution doctrine @@ -84,6 +85,8 @@ Use the runtime-readiness command before public launch or deployment approval: - the auth server can now also serve the built `website/dist` bundle directly for same-origin `hypertwist.app` deployment when that build output is present - it now warns when same-origin public deployment leaves static website serving mode ambiguous - request-level server tests now also pin that same-origin shell behavior instead of relying only on helper-level assertions +- the public pricing/download/notices pages now also surface preview-versus-launch posture directly from the same bounded launch checklist +- the real `check-runtime-readiness` CLI is now also exercised against the checked-in `.env.production.example` files so placeholder launch scaffolds cannot silently drift away from the documented command The repo bootstrap CI now also validates this lane through: @@ -96,8 +99,11 @@ The focused frontend test coverage now also pins: - safe `next`-path normalization across custom auth pages and SuperTokens redirect handoff - auth-bootstrap normalization when fallback/email sessions are re-hydrated - login/register page continuation behavior and protected-dashboard download gating on the public download page +- platform-preserving `/download -> /app/downloads?platform=...` continuation plus requested-target surfacing inside the protected release lane +- support-topic fallback routing for pricing and launch-readiness actions when live checkout is not configured yet - protected-route loading/redirect behavior plus auth-aware marketing/app shell actions - real `AppRouteTree` smoke coverage for `/`, `/pricing`, `/download`, `/login`, `/app`, and `/app/downloads` - top-level `App` bootstrap coverage for unknown-route redirect and SuperTokens wrapper on/off posture - login/register unhappy-path coverage for returned form errors, auth-runtime warning callouts, and OAuth-button visibility/invocation - dashboard launch-readiness plus desktop-link verify-url behavior +- spawned `website/server` bootstrap proof from production-shaped same-origin env into live `/health`, `/api/auth/health`, and static public/app shell delivery diff --git a/website/scripts/runtime-readiness-cli.test.mjs b/website/scripts/runtime-readiness-cli.test.mjs new file mode 100644 index 0000000..ef108c1 --- /dev/null +++ b/website/scripts/runtime-readiness-cli.test.mjs @@ -0,0 +1,44 @@ +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +const currentFile = fileURLToPath(import.meta.url) +const websiteRoot = path.resolve(path.dirname(currentFile), '..') +const frontendProductionEnv = path.join(websiteRoot, '.env.production.example') +const serverProductionEnv = path.join(websiteRoot, 'server', '.env.production.example') +const runtimeReadinessScript = path.join(websiteRoot, 'scripts', 'check-runtime-readiness.mjs') + +describe('check-runtime-readiness CLI', () => { + it('fails the real production example env files until placeholder launch values are replaced', () => { + const result = spawnSync( + process.execPath, + [ + runtimeReadinessScript, + '--frontend-env', + frontendProductionEnv, + '--server-env', + serverProductionEnv, + '--skip-live-health', + '--json', + ], + { + cwd: websiteRoot, + encoding: 'utf8', + }, + ) + + expect(result.status).toBe(1) + expect(result.stderr).toBe('') + + const report = JSON.parse(result.stdout) + expect(report.ok).toBe(false) + expect(report.failures).toContain('VITE_WINDOWS_DOWNLOAD_URL still contains a placeholder value.') + expect(report.failures).toContain('VITE_PADDLE_CHECKOUT_URL_OPERATOR still contains a placeholder value.') + expect(report.failures).toContain('VITE_MPL_SOURCE_URL still contains a placeholder value.') + expect(report.failures).toContain('PADDLE_WEBHOOK_SECRET still contains a placeholder value.') + expect(report.failures).toContain('PADDLE_PRODUCT_PLAN_MAP still contains a placeholder value.') + expect(report.failures).toContain('PADDLE_PRICE_PLAN_MAP still contains a placeholder value.') + }) +}) diff --git a/website/server/README.md b/website/server/README.md index 3e21717..f6a977d 100644 --- a/website/server/README.md +++ b/website/server/README.md @@ -83,6 +83,7 @@ The server now also supports a bounded first-party same-origin deployment mode: - `SERVE_STATIC_WEBSITE=true` forces the server to expect a built bundle, while `SERVE_STATIC_WEBSITE=false` keeps api-only mode explicit - the example env files now carry those static-serving controls directly so deployment posture is not implicit - request-level tests now also prove that public/app routes serve the shell while `/api/*`, `/auth*`, `/health`, and missing asset paths remain unshadowed +- a spawned bootstrap test now also proves the real `src/index.ts` process can start from production-shaped same-origin env, reach a ready SuperTokens core, expose green `/api/auth/health`, and serve the built shell The website package now also ships a deploy-time verification command: @@ -98,6 +99,10 @@ Keep the example env files separated by posture: - `website/.env.example` and `website/server/.env.example` remain local-development defaults - `website/.env.production.example` and `website/server/.env.production.example` are public-posture scaffolds that still intentionally fail readiness until placeholder values are replaced +The repo now also proves that behavior directly by running the real +`check-runtime-readiness` CLI against those production example files in test +mode and asserting that placeholder launch values still fail. + The repo bootstrap CI now also validates this auth-server lane through: - `npm ci` diff --git a/website/server/src/__tests__/server-bootstrap.test.ts b/website/server/src/__tests__/server-bootstrap.test.ts new file mode 100644 index 0000000..972e8a5 --- /dev/null +++ b/website/server/src/__tests__/server-bootstrap.test.ts @@ -0,0 +1,298 @@ +// @vitest-environment node + +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { spawn, type ChildProcessByStdio } from 'node:child_process' +import { createServer, type Server } from 'node:http' +import { once } from 'node:events' +import { networkInterfaces } from 'node:os' +import path from 'node:path' +import os from 'node:os' +import { fileURLToPath } from 'node:url' +import type { Readable } from 'node:stream' +import { afterEach, describe, expect, it } from 'vitest' + +const tempRoots: string[] = [] +type SpawnedServerProcess = ChildProcessByStdio + +const childProcesses = new Set() +const servers = new Set() +const currentDirectory = path.dirname(fileURLToPath(import.meta.url)) + +function makeTempRoot() { + const root = mkdtempSync(path.join(os.tmpdir(), 'hypertwist-server-bootstrap-')) + tempRoots.push(root) + return root +} + +function createDistBundle(root: string) { + const distPath = path.join(root, 'dist') + mkdirSync(path.join(distPath, 'assets'), { recursive: true }) + writeFileSync( + path.join(distPath, 'index.html'), + '
HyperTwist bootstrap shell
', + 'utf8', + ) + writeFileSync(path.join(distPath, 'assets', 'app.js'), 'console.log("bootstrap");', 'utf8') + return distPath +} + +function getNonLoopbackIpv4() { + const interfaces = networkInterfaces() + for (const entries of Object.values(interfaces)) { + for (const entry of entries || []) { + if (entry && entry.family === 'IPv4' && !entry.internal) { + return entry.address + } + } + } + return null +} + +async function getFreePort(host = '127.0.0.1') { + const server = createServer() + servers.add(server) + server.listen(0, host) + await once(server, 'listening') + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Failed to resolve a free port') + } + const port = address.port + server.close() + servers.delete(server) + return port +} + +async function startFakeCoreServer(host: string) { + const server = createServer((req, res) => { + if ((req.url || '').startsWith('/apiversion')) { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ versions: ['5.0'] })) + return + } + + res.writeHead(404, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ ok: false, path: req.url || '' })) + }) + + servers.add(server) + server.listen(0, host) + await once(server, 'listening') + + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Failed to bind fake SuperTokens core server') + } + + return { + server, + connectionUri: `http://${host}:${address.port}`, + } +} + +function readStreamText(value: unknown) { + return Buffer.isBuffer(value) ? value.toString('utf8') : String(value || '') +} + +function spawnAuthServer(env: NodeJS.ProcessEnv) { + const serverRoot = path.resolve(currentDirectory, '../..') + const tsxCliPath = path.join(serverRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs') + const child = spawn(process.execPath, [tsxCliPath, 'src/index.ts'], { + cwd: serverRoot, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + childProcesses.add(child) + + const logs = { + stdout: '', + stderr: '', + } + + child.stdout.on('data', (chunk) => { + logs.stdout += readStreamText(chunk) + }) + child.stderr.on('data', (chunk) => { + logs.stderr += readStreamText(chunk) + }) + + return { child, logs } +} + +async function waitForHttpReady(baseUrl: string, child: SpawnedServerProcess, logs: { stdout: string; stderr: string }) { + const startedAt = Date.now() + + while (Date.now() - startedAt < 15_000) { + if (child.exitCode !== null) { + throw new Error( + `Auth server exited before becoming ready (code ${child.exitCode}).\nSTDOUT:\n${logs.stdout}\nSTDERR:\n${logs.stderr}`, + ) + } + + try { + const response = await fetch(`${baseUrl}/health`) + if (response.ok) { + return + } + } catch { + // keep polling until ready or timeout + } + + await new Promise((resolve) => setTimeout(resolve, 150)) + } + + throw new Error(`Timed out waiting for auth server readiness.\nSTDOUT:\n${logs.stdout}\nSTDERR:\n${logs.stderr}`) +} + +async function stopChildProcess(child: SpawnedServerProcess) { + if (child.exitCode !== null) { + childProcesses.delete(child) + return + } + + child.kill('SIGTERM') + const exitResult = await Promise.race([ + once(child, 'exit'), + new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 5_000)), + ]) + + if (exitResult === 'timeout' && child.exitCode === null) { + child.kill('SIGKILL') + await once(child, 'exit') + } + + childProcesses.delete(child) +} + +afterEach(async () => { + const children = Array.from(childProcesses) + childProcesses.clear() + await Promise.all(children.map((child) => stopChildProcess(child))) + + for (const server of Array.from(servers)) { + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + } + servers.delete(server) + } + + while (tempRoots.length > 0) { + const root = tempRoots.pop() + if (root) { + rmSync(root, { recursive: true, force: true }) + } + } +}) + +describe('website/server bootstrap', () => { + it('boots with production-shaped same-origin config and serves live health plus the built website shell', async () => { + const coreHost = getNonLoopbackIpv4() + if (!coreHost) { + throw new Error('A non-loopback IPv4 interface is required for public-posture bootstrap proof.') + } + + const root = makeTempRoot() + const distPath = createDistBundle(root) + const billingStatePath = path.join(root, 'billing-state.json') + const fakeCore = await startFakeCoreServer(coreHost) + const serverPort = await getFreePort('127.0.0.1') + const baseUrl = `http://127.0.0.1:${serverPort}` + + const { child, logs } = spawnAuthServer({ + ...process.env, + PORT: String(serverPort), + SUPERTOKENS_CORE_URI: fakeCore.connectionUri, + API_DOMAIN: 'https://hypertwist.app', + WEBSITE_DOMAIN: 'https://hypertwist.app', + API_BASE_PATH: '/auth', + WEBSITE_BASE_PATH: '/auth', + COOKIE_SECURE: 'true', + SERVE_STATIC_WEBSITE: 'true', + WEBSITE_DIST_PATH: distPath, + DEFAULT_PLAN: 'free', + DEFAULT_ROLE: 'operator', + SUPERTOKENS_HEALTH_TIMEOUT_MS: '1500', + DESKTOP_LINK_RATE_LIMIT_MAX: '5', + DESKTOP_LINK_RATE_LIMIT_WINDOW_MS: '900000', + PADDLE_WEBHOOK_SECRET: 'real-secret-value', + BILLING_STATE_PATH: billingStatePath, + PADDLE_PRODUCT_PLAN_MAP: '{"prod_operator":"operator","prod_studio":"studio"}', + PADDLE_PRICE_PLAN_MAP: '{"pri_operator":"operator","pri_studio":"studio"}', + GITHUB_CLIENT_ID: '', + GITHUB_CLIENT_SECRET: '', + GOOGLE_CLIENT_ID: '', + GOOGLE_CLIENT_SECRET: '', + }) + + try { + await waitForHttpReady(baseUrl, child, logs) + + const healthResponse = await fetch(`${baseUrl}/health`) + expect(healthResponse.status).toBe(200) + expect(await healthResponse.json()).toEqual({ + ok: true, + service: 'hypertwist-auth-server', + }) + + const authHealthResponse = await fetch(`${baseUrl}/api/auth/health`) + expect(authHealthResponse.status).toBe(200) + expect(await authHealthResponse.json()).toMatchObject({ + ok: true, + service: 'hypertwist-auth-server', + supertokens: { + configured: true, + reachable: true, + ready: true, + apiVersion: '5.0', + oauth: { + github: false, + google: false, + }, + }, + fallback: { + enabled: true, + active: false, + reason: null, + }, + billing: { + processedEventCount: 0, + productPlanMapConfigured: true, + pricePlanMapConfigured: true, + webhookSecretConfigured: true, + }, + runtime: { + mode: 'public', + public_origin_ready: true, + cookie_secure: true, + api_domain: 'https://hypertwist.app', + website_domain: 'https://hypertwist.app', + warnings: [], + errors: [], + }, + }) + + const aboutResponse = await fetch(`${baseUrl}/about`) + expect(aboutResponse.status).toBe(200) + expect(await aboutResponse.text()).toContain('HyperTwist bootstrap shell') + + const downloadsResponse = await fetch(`${baseUrl}/app/downloads`) + expect(downloadsResponse.status).toBe(200) + expect(await downloadsResponse.text()).toContain('HyperTwist bootstrap shell') + + const missingAssetResponse = await fetch(`${baseUrl}/assets/missing.js`) + expect(missingAssetResponse.status).toBe(404) + expect(await missingAssetResponse.text()).toContain('Cannot GET /assets/missing.js') + } finally { + await stopChildProcess(child) + } + }) +}) diff --git a/website/src/__tests__/download-center-page.test.tsx b/website/src/__tests__/download-center-page.test.tsx new file mode 100644 index 0000000..1bbdc1f --- /dev/null +++ b/website/src/__tests__/download-center-page.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, expect, it, vi } from 'vitest' +import { ROUTER_FUTURE_FLAGS } from '../router/router-future' + +vi.mock('../auth/platform-auth', () => ({ + usePlatformAuth: () => ({ + user: { + canDownload: true, + }, + }), +})) + +vi.mock('../site-config', () => ({ + brandConfig: { + brandName: 'HyperTwist', + legalName: 'HyperTwist', + domain: 'hypertwist.app', + tagline: 'Native cube and hypercube training, from first solve to 120-cell.', + contact: { + email: 'hello@hypertwist.app', + emailHref: 'mailto:hello@hypertwist.app', + }, + payment: { + provider: 'Paddle', + }, + }, + downloadTargets: [ + { + platformKey: 'windows', + platform: 'Windows', + subtitle: 'Primary shipping lane', + href: 'https://downloads.hypertwist.app/windows.exe', + details: 'Current packaged validation is strongest on the Windows Unreal lane.', + }, + { + platformKey: 'macos', + platform: 'macOS', + subtitle: 'Planned distribution surface', + href: 'https://downloads.hypertwist.app/macos.dmg', + details: 'List a signed desktop build here when the package lane is opened.', + }, + { + platformKey: 'linux', + platform: 'Linux', + subtitle: 'Operator-targeted later lane', + href: '', + details: 'Use for future package publication after the bounded release lane is widened.', + }, + ], + launchReadiness: { + operatorCheckoutConfigured: true, + studioCheckoutConfigured: true, + windowsDownloadConfigured: true, + macDownloadConfigured: true, + linuxDownloadConfigured: false, + mplSourceConfigured: true, + openSourceRepoConfigured: true, + }, + mplSourceUrl: 'https://hypertwist.app/open-source/source.zip', + openSourceRepoUrl: 'https://github.com/hypertwist/hypertwist', + publicDocsUrl: 'https://docs.hypertwist.app', + releaseNotesUrl: 'https://notes.hypertwist.app', +})) + +import { DownloadCenterPage } from '../pages/app-pages' + +describe('DownloadCenterPage', () => { + it('surfaces the requested protected release target when the public site passes a platform hint through auth', () => { + render( + + + , + ) + + expect(screen.getByText(/preserved your Windows selection/i)).toBeTruthy() + expect(screen.getAllByText('Requested target').length).toBeGreaterThan(0) + expect(screen.getByRole('link', { name: /download windows/i }).getAttribute('href')).toBe('https://downloads.hypertwist.app/windows.exe') + }) +}) diff --git a/website/src/__tests__/public-auth-pages.test.tsx b/website/src/__tests__/public-auth-pages.test.tsx index b7c49f6..befebeb 100644 --- a/website/src/__tests__/public-auth-pages.test.tsx +++ b/website/src/__tests__/public-auth-pages.test.tsx @@ -29,18 +29,21 @@ vi.mock('../site-config', async () => { ...actual, downloadTargets: [ { + platformKey: 'windows', platform: 'Windows', subtitle: 'Primary shipping lane', href: 'https://downloads.hypertwist.app/windows.exe', details: 'Current packaged validation is strongest on the Windows Unreal lane.', }, { + platformKey: 'macos', platform: 'macOS', subtitle: 'Planned distribution surface', href: '', details: 'List a signed desktop build here when the package lane is opened.', }, { + platformKey: 'linux', platform: 'Linux', subtitle: 'Operator-targeted later lane', href: '', @@ -204,7 +207,7 @@ describe('public auth and download pages', () => { ) const windowsAccessLink = screen.getByRole('link', { name: /sign in for windows access/i }) - expect(windowsAccessLink.getAttribute('href')).toBe('/app/downloads') + expect(windowsAccessLink.getAttribute('href')).toBe('/app/downloads?platform=windows') expect(screen.queryByText('https://downloads.hypertwist.app/windows.exe')).toBeNull() }) }) diff --git a/website/src/__tests__/public-launch.test.ts b/website/src/__tests__/public-launch.test.ts new file mode 100644 index 0000000..1d1430d --- /dev/null +++ b/website/src/__tests__/public-launch.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { + getMissingPublicLaunchChecklistItems, + getPublicLaunchChecklist, + isPublicLaunchReady, +} from '../public-launch' + +describe('public launch readiness helpers', () => { + it('reports the missing public launch requirements that still block external launch', () => { + const missingItems = getMissingPublicLaunchChecklistItems({ + windowsDownloadConfigured: true, + operatorCheckoutConfigured: false, + studioCheckoutConfigured: false, + mplSourceConfigured: false, + openSourceRepoConfigured: true, + }) + + expect(missingItems.map((item) => item.label)).toEqual([ + 'Operator checkout', + 'Studio checkout', + 'Corresponding-source URL', + ]) + expect(isPublicLaunchReady({ + windowsDownloadConfigured: true, + operatorCheckoutConfigured: false, + studioCheckoutConfigured: false, + mplSourceConfigured: false, + openSourceRepoConfigured: true, + })).toBe(false) + }) + + it('treats the bounded website lane as launch-ready only when the full checklist is configured', () => { + const checklist = getPublicLaunchChecklist({ + windowsDownloadConfigured: true, + operatorCheckoutConfigured: true, + studioCheckoutConfigured: true, + mplSourceConfigured: true, + openSourceRepoConfigured: true, + }) + + expect(checklist.every((item) => item.configured)).toBe(true) + expect(isPublicLaunchReady({ + windowsDownloadConfigured: true, + operatorCheckoutConfigured: true, + studioCheckoutConfigured: true, + mplSourceConfigured: true, + openSourceRepoConfigured: true, + })).toBe(true) + }) +}) diff --git a/website/src/__tests__/public-marketing-pages.test.tsx b/website/src/__tests__/public-marketing-pages.test.tsx new file mode 100644 index 0000000..2314278 --- /dev/null +++ b/website/src/__tests__/public-marketing-pages.test.tsx @@ -0,0 +1,132 @@ +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, expect, it, vi } from 'vitest' +import { ROUTER_FUTURE_FLAGS } from '../router/router-future' + +vi.mock('../auth/platform-auth', () => ({ + usePlatformAuth: () => ({ + isAuthenticated: false, + }), +})) + +vi.mock('../site-config', () => ({ + brandConfig: { + brandName: 'HyperTwist', + legalName: 'HyperTwist', + domain: 'hypertwist.app', + tagline: 'Native cube and hypercube training, from first solve to 120-cell.', + contact: { + email: 'hello@hypertwist.app', + emailHref: 'mailto:hello@hypertwist.app', + }, + payment: { + provider: 'Paddle', + }, + }, + downloadTargets: [ + { + platformKey: 'windows', + platform: 'Windows', + subtitle: 'Primary shipping lane', + href: 'https://downloads.hypertwist.app/windows.exe', + details: 'Current packaged validation is strongest on the Windows Unreal lane.', + }, + { + platformKey: 'macos', + platform: 'macOS', + subtitle: 'Planned distribution surface', + href: '', + details: 'List a signed desktop build here when the package lane is opened.', + }, + { + platformKey: 'linux', + platform: 'Linux', + subtitle: 'Operator-targeted later lane', + href: '', + details: 'Use for future package publication after the bounded release lane is widened.', + }, + ], + planCatalog: [ + { + key: 'explorer', + name: 'Explorer', + price: 'Free', + ctaLabel: 'Start in the browser', + ctaHref: '/register', + notes: 'Preview the browser account shell, operator dashboard, release notes, and desktop onboarding posture.', + features: ['Browser account access'], + }, + { + key: 'operator', + name: 'Operator', + price: 'Launch pricing via Paddle', + ctaLabel: 'Request operator access', + ctaHref: '/support?topic=operator-access', + notes: 'Desktop-first recognition, replay, training, and higher-dimensional runtime ownership for active training operators.', + features: ['Desktop download access'], + }, + { + key: 'studio', + name: 'Studio', + price: 'Contact for launch readiness', + ctaLabel: 'Talk to HyperTwist', + ctaHref: '/support?topic=studio-rollout', + notes: 'Higher-dimensional families, operator deployment support, and packaging/validation coordination for production lanes.', + features: ['Magic120Cell and 5D operator posture'], + }, + ], + paddleReadyDescription: + 'HyperTwist is a desktop-first training environment with Paddle-ready checkout and launch-honest public distribution surfaces.', + publicDocsUrl: 'https://docs.hypertwist.app', + releaseNotesUrl: 'https://notes.hypertwist.app', + mplSourceUrl: '', + openSourceRepoUrl: '', + launchReadiness: { + operatorCheckoutConfigured: false, + studioCheckoutConfigured: false, + windowsDownloadConfigured: true, + macDownloadConfigured: false, + linuxDownloadConfigured: false, + mplSourceConfigured: false, + openSourceRepoConfigured: false, + }, +})) + +import { DownloadPage, PricingPage, SupportPage } from '../pages/public-pages' + +describe('public marketing pages', () => { + it('routes public download access through the protected dashboard with the requested platform preserved', () => { + render( + + + , + ) + + expect(screen.getByRole('link', { name: /sign in for windows access/i }).getAttribute('href')).toBe('/app/downloads?platform=windows') + expect(screen.getByText('Preview posture')).toBeTruthy() + expect(screen.getByText('Operator checkout: missing')).toBeTruthy() + }) + + it('uses useful support fallback routes for plans that do not yet have live checkouts', () => { + render( + + + , + ) + + expect(screen.getByRole('link', { name: /request operator access/i }).getAttribute('href')).toBe('/support?topic=operator-access') + expect(screen.getByRole('link', { name: /talk to hypertwist/i }).getAttribute('href')).toBe('/support?topic=studio-rollout') + }) + + it('surfaces the preselected support lane when marketing routes send the operator here intentionally', () => { + render( + + + , + ) + + expect(screen.getByText('Selected help lane')).toBeTruthy() + expect(screen.getByText('Launch readiness')).toBeTruthy() + expect(screen.getByText(/turning the preview lane into a public launch/i)).toBeTruthy() + }) +}) diff --git a/website/src/__tests__/site-routes.test.ts b/website/src/__tests__/site-routes.test.ts new file mode 100644 index 0000000..b69cb00 --- /dev/null +++ b/website/src/__tests__/site-routes.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { + buildProtectedDownloadPath, + buildSupportPath, + isExternalHref, + normalizeDownloadPlatform, +} from '../site-routes' + +describe('site route helpers', () => { + it('builds protected download paths that preserve the requested platform', () => { + expect(buildProtectedDownloadPath('windows')).toBe('/app/downloads?platform=windows') + expect(buildProtectedDownloadPath('macos')).toBe('/app/downloads?platform=macos') + expect(buildProtectedDownloadPath()).toBe('/app/downloads') + }) + + it('normalizes supported platform query values and rejects unknown values', () => { + expect(normalizeDownloadPlatform(' Windows ')).toBe('windows') + expect(normalizeDownloadPlatform('linux')).toBe('linux') + expect(normalizeDownloadPlatform('android')).toBeNull() + }) + + it('builds support topic routes and distinguishes external URLs', () => { + expect(buildSupportPath('launch-readiness')).toBe('/support?topic=launch-readiness') + expect(buildSupportPath()).toBe('/support') + expect(isExternalHref('https://checkout.paddle.com')).toBe(true) + expect(isExternalHref('/support?topic=operator-access')).toBe(false) + }) +}) diff --git a/website/src/components/ui/PublicLaunchStatus.tsx b/website/src/components/ui/PublicLaunchStatus.tsx new file mode 100644 index 0000000..d724ad2 --- /dev/null +++ b/website/src/components/ui/PublicLaunchStatus.tsx @@ -0,0 +1,48 @@ +import { Link } from 'react-router-dom' +import { + getMissingPublicLaunchChecklistItems, + getPublicLaunchChecklist, + isPublicLaunchReady, +} from '../../public-launch' +import { buildSupportPath } from '../../site-routes' + +export function PublicLaunchStatus({ + title = 'Current public launch status', +}: { + title?: string +}) { + const checklist = getPublicLaunchChecklist() + const missingItems = getMissingPublicLaunchChecklistItems() + const ready = isPublicLaunchReady() + + return ( +
+

+ {ready ? 'Launch-ready posture' : 'Preview posture'} +

+

{title}

+

+ {ready + ? 'The bounded public website lane now has the core download, checkout, and notice configuration needed for external launch.' + : `The public website is live, but external launch still needs ${missingItems.map((item) => item.label.toLowerCase()).join(', ')}.`} +

+
    + {checklist.map((item) => ( +
  • + {item.label}: {item.configured ? 'configured' : 'missing'} +
  • + ))} +
+ {!ready ? ( +
+ + Contact launch support + + + Review notices + +
+ ) : null} +
+ ) +} diff --git a/website/src/pages/app-pages.tsx b/website/src/pages/app-pages.tsx index 249c09a..0b5df15 100644 --- a/website/src/pages/app-pages.tsx +++ b/website/src/pages/app-pages.tsx @@ -1,8 +1,10 @@ import { useMemo } from 'react' import { useMutation, useQuery } from '@tanstack/react-query' +import { useSearchParams } from 'react-router-dom' import { buildAuthApiBaseUrls, createDesktopLinkToken, getAuthHealth } from '../auth/auth-api' import { usePlatformAuth } from '../auth/platform-auth' import { downloadTargets, launchReadiness, mplSourceUrl } from '../site-config' +import { getDownloadPlatformLabel, normalizeDownloadPlatform } from '../site-routes' import { roadmapHonestyCards } from '../site-data' function Panel({ @@ -179,7 +181,25 @@ export function DashboardOverviewPage() { export function DownloadCenterPage() { const { user } = usePlatformAuth() + const [searchParams] = useSearchParams() const canDownload = user?.canDownload === true + const requestedPlatform = normalizeDownloadPlatform(searchParams.get('platform')) + const orderedTargets = useMemo(() => { + if (!requestedPlatform) { + return downloadTargets + } + + const prioritizedTarget = downloadTargets.find((target) => target.platformKey === requestedPlatform) + if (!prioritizedTarget) { + return downloadTargets + } + + return [ + prioritizedTarget, + ...downloadTargets.filter((target) => target.platformKey !== requestedPlatform), + ] + }, [requestedPlatform]) + const requestedPlatformLabel = requestedPlatform ? getDownloadPlatformLabel(requestedPlatform) : null return (
@@ -190,9 +210,26 @@ export function DownloadCenterPage() { Complete the matching checkout or operator provisioning step, then refresh this dashboard.

) : null} + {requestedPlatformLabel ? ( +
+

Requested target

+

+ The browser handoff preserved your {requestedPlatformLabel} selection so you land on the matching protected release lane first. +

+
+ ) : null}
- {downloadTargets.map((target) => ( -
+ {orderedTargets.map((target) => ( +
+
+ {target.platformKey === requestedPlatform ? ( +

Requested target

+ ) : null} +

{target.subtitle}

+

{target.platform}

{target.details}

{target.href && canDownload ? ( diff --git a/website/src/pages/public-pages.tsx b/website/src/pages/public-pages.tsx index 5d09808..db0b76d 100644 --- a/website/src/pages/public-pages.tsx +++ b/website/src/pages/public-pages.tsx @@ -1,7 +1,8 @@ import { useDeferredValue, useMemo, useState } from 'react' import { ArrowRight, BookOpenText, Boxes, Download, ExternalLink, Landmark, MonitorCog, Sparkles } from 'lucide-react' -import { Link } from 'react-router-dom' +import { Link, useSearchParams } from 'react-router-dom' import { MarketingShell } from '../components/layout/MarketingShell' +import { PublicLaunchStatus } from '../components/ui/PublicLaunchStatus' import { brandConfig, downloadTargets, @@ -10,6 +11,7 @@ import { publicDocsUrl, releaseNotesUrl, } from '../site-config' +import { buildProtectedDownloadPath, isExternalHref } from '../site-routes' import { capabilityPillars, changelogEntries, @@ -23,6 +25,21 @@ import { supportFaqs, } from '../site-data' +const supportTopicGuidance: Record = { + 'launch-readiness': { + title: 'Launch readiness', + description: 'Need help turning the preview lane into a public launch? We can walk through checkout wiring, download release targets, notices, and corresponding-source publication.', + }, + 'operator-access': { + title: 'Operator access', + description: 'Use this lane when you need operator checkout, entitlement enablement, or help reaching the protected desktop-download surface.', + }, + 'studio-rollout': { + title: 'Studio rollout', + description: 'Use this lane for higher-dimensional rollout planning, deployment coordination, or production-lane package and notice readiness.', + }, +} + function Section({ title, description, @@ -43,6 +60,22 @@ function Section({ ) } +function PlanActionLink({ href, label }: { href: string; label: string }) { + if (isExternalHref(href)) { + return ( + + {label} + + ) + } + + return ( + + {label} + + ) +} + export function HomeLanding() { return ( + {selectedSupportTopic ? ( +
+
+

{selectedSupportTopic.title}

+

{selectedSupportTopic.description}

+
+
+ ) : null} +
))}
+
+ +
+

@@ -421,7 +469,7 @@ export function DownloadPage() {

{target.subtitle}

{target.details}

{target.href ? ( - + Sign in for {target.platform} access ) : ( @@ -434,6 +482,10 @@ export function DownloadPage() {
+
+ +
+

@@ -513,6 +565,10 @@ export function OpenSourceNoticesPage() {

+ +
+ +
) } diff --git a/website/src/public-launch.ts b/website/src/public-launch.ts new file mode 100644 index 0000000..0507aa3 --- /dev/null +++ b/website/src/public-launch.ts @@ -0,0 +1,64 @@ +import { launchReadiness } from './site-config' + +type PublicLaunchChecklistId = + | 'windows-download' + | 'operator-checkout' + | 'studio-checkout' + | 'mpl-source' + | 'open-source-notices' + +export type PublicLaunchChecklistItem = { + id: PublicLaunchChecklistId + label: string + configured: boolean +} + +type PublicLaunchReadiness = { + windowsDownloadConfigured: boolean + operatorCheckoutConfigured: boolean + studioCheckoutConfigured: boolean + mplSourceConfigured: boolean + openSourceRepoConfigured: boolean +} + +export function getPublicLaunchChecklist( + readiness: PublicLaunchReadiness = launchReadiness, +): PublicLaunchChecklistItem[] { + return [ + { + id: 'windows-download', + label: 'Windows download URL', + configured: readiness.windowsDownloadConfigured, + }, + { + id: 'operator-checkout', + label: 'Operator checkout', + configured: readiness.operatorCheckoutConfigured, + }, + { + id: 'studio-checkout', + label: 'Studio checkout', + configured: readiness.studioCheckoutConfigured, + }, + { + id: 'mpl-source', + label: 'Corresponding-source URL', + configured: readiness.mplSourceConfigured, + }, + { + id: 'open-source-notices', + label: 'Open-source notices/repository URL', + configured: readiness.openSourceRepoConfigured, + }, + ] +} + +export function getMissingPublicLaunchChecklistItems( + readiness: PublicLaunchReadiness = launchReadiness, +): PublicLaunchChecklistItem[] { + return getPublicLaunchChecklist(readiness).filter((item) => !item.configured) +} + +export function isPublicLaunchReady(readiness: PublicLaunchReadiness = launchReadiness): boolean { + return getMissingPublicLaunchChecklistItems(readiness).length === 0 +} diff --git a/website/src/site-config.ts b/website/src/site-config.ts index ff8bfdb..e141ef0 100644 --- a/website/src/site-config.ts +++ b/website/src/site-config.ts @@ -1,3 +1,6 @@ +import type { DownloadPlatformKey } from './site-routes' +import { buildSupportPath } from './site-routes' + const env = import.meta.env as Record function readTrimmedEnv(name: string, fallback = '') { @@ -21,6 +24,14 @@ export const brandConfig = { const operatorCheckoutUrl = readTrimmedEnv('VITE_PADDLE_CHECKOUT_URL_OPERATOR') const studioCheckoutUrl = readTrimmedEnv('VITE_PADDLE_CHECKOUT_URL_STUDIO') +type DownloadTarget = { + platformKey: DownloadPlatformKey + platform: string + subtitle: string + href: string + details: string +} + export const planCatalog = [ { key: 'explorer', @@ -41,7 +52,7 @@ export const planCatalog = [ name: 'Operator', price: readTrimmedEnv('VITE_PLAN_PRICE_OPERATOR', 'Launch pricing via Paddle'), ctaLabel: operatorCheckoutUrl ? 'Open Paddle checkout' : 'Request operator access', - ctaHref: readTrimmedEnv('VITE_PADDLE_CHECKOUT_URL_OPERATOR', '/pricing'), + ctaHref: operatorCheckoutUrl || buildSupportPath('operator-access'), notes: 'Desktop-first recognition, replay, training, and higher-dimensional runtime ownership for active training operators.', features: [ 'Desktop download access', @@ -55,7 +66,7 @@ export const planCatalog = [ name: 'Studio', price: readTrimmedEnv('VITE_PLAN_PRICE_STUDIO', 'Contact for launch readiness'), ctaLabel: studioCheckoutUrl ? 'Open Paddle checkout' : 'Talk to HyperTwist', - ctaHref: readTrimmedEnv('VITE_PADDLE_CHECKOUT_URL_STUDIO', '/support'), + ctaHref: studioCheckoutUrl || buildSupportPath('studio-rollout'), notes: 'Higher-dimensional families, operator deployment support, and packaging/validation coordination for production lanes.', features: [ 'Magic120Cell and 5D operator posture', @@ -68,24 +79,27 @@ export const planCatalog = [ export const downloadTargets = [ { + platformKey: 'windows', platform: 'Windows', subtitle: 'Primary shipping lane', href: readTrimmedEnv('VITE_WINDOWS_DOWNLOAD_URL'), details: 'Current packaged validation is strongest on the Windows Unreal lane.', }, { + platformKey: 'macos', platform: 'macOS', subtitle: 'Planned distribution surface', href: readTrimmedEnv('VITE_MAC_DOWNLOAD_URL'), details: 'List a signed desktop build here when the package lane is opened.', }, { + platformKey: 'linux', platform: 'Linux', subtitle: 'Operator-targeted later lane', href: readTrimmedEnv('VITE_LINUX_DOWNLOAD_URL'), details: 'Use for future package publication after the bounded release lane is widened.', }, -] as const +] as const satisfies readonly DownloadTarget[] export const publicDocsUrl = readTrimmedEnv('VITE_PUBLIC_DOCS_URL') export const releaseNotesUrl = readTrimmedEnv('VITE_RELEASE_NOTES_URL') diff --git a/website/src/site-routes.ts b/website/src/site-routes.ts new file mode 100644 index 0000000..ecce67b --- /dev/null +++ b/website/src/site-routes.ts @@ -0,0 +1,48 @@ +export type DownloadPlatformKey = 'windows' | 'macos' | 'linux' + +const DOWNLOAD_PLATFORM_LABELS: Record = { + windows: 'Windows', + macos: 'macOS', + linux: 'Linux', +} + +const DOWNLOAD_PLATFORM_KEYS = Object.keys(DOWNLOAD_PLATFORM_LABELS) as DownloadPlatformKey[] + +export function normalizeDownloadPlatform(value: string | null | undefined): DownloadPlatformKey | null { + const normalized = String(value || '').trim().toLowerCase() + if (!normalized) { + return null + } + + return DOWNLOAD_PLATFORM_KEYS.includes(normalized as DownloadPlatformKey) + ? (normalized as DownloadPlatformKey) + : null +} + +export function getDownloadPlatformLabel(platform: DownloadPlatformKey): string { + return DOWNLOAD_PLATFORM_LABELS[platform] +} + +export function buildProtectedDownloadPath(platform?: DownloadPlatformKey | null): string { + const normalizedPlatform = normalizeDownloadPlatform(platform) + if (!normalizedPlatform) { + return '/app/downloads' + } + + const params = new URLSearchParams({ platform: normalizedPlatform }) + return `/app/downloads?${params.toString()}` +} + +export function buildSupportPath(topic?: string): string { + const normalizedTopic = String(topic || '').trim() + if (!normalizedTopic) { + return '/support' + } + + const params = new URLSearchParams({ topic: normalizedTopic }) + return `/support?${params.toString()}` +} + +export function isExternalHref(value: string): boolean { + return /^[a-z][a-z0-9+.-]*:/i.test(value) +} diff --git a/website/src/styles/global.css b/website/src/styles/global.css index 3b37a16..092dbe0 100644 --- a/website/src/styles/global.css +++ b/website/src/styles/global.css @@ -356,6 +356,11 @@ img { padding: 1.15rem; } +.card--selected { + border-color: var(--ht-border-strong); + box-shadow: inset 0 0 0 1px rgba(247, 178, 103, 0.16); +} + .card--compact { display: grid; gap: 0.8rem; @@ -383,6 +388,27 @@ img { font-weight: 700; } +.status-pill--info { + background: var(--ht-cyan-soft); + color: var(--ht-cyan); +} + +.status-pill--success { + background: rgba(158, 255, 199, 0.14); + color: var(--ht-success); +} + +.status-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; +} + +.status-row .status-pill { + margin-bottom: 0; +} + .list { margin: 0; padding-left: 1.15rem;