Prove website production deployment posture
This commit is contained in:
parent
d11bf59a70
commit
e4fcff160b
20 changed files with 983 additions and 15 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
44
website/scripts/runtime-readiness-cli.test.mjs
Normal file
44
website/scripts/runtime-readiness-cli.test.mjs
Normal file
|
|
@ -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.')
|
||||
})
|
||||
})
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
298
website/server/src/__tests__/server-bootstrap.test.ts
Normal file
298
website/server/src/__tests__/server-bootstrap.test.ts
Normal file
|
|
@ -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<null, Readable, Readable>
|
||||
|
||||
const childProcesses = new Set<SpawnedServerProcess>()
|
||||
const servers = new Set<Server>()
|
||||
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'),
|
||||
'<!doctype html><html><body><div id="app">HyperTwist bootstrap shell</div></body></html>',
|
||||
'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<void>((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)
|
||||
}
|
||||
})
|
||||
})
|
||||
80
website/src/__tests__/download-center-page.test.tsx
Normal file
80
website/src/__tests__/download-center-page.test.tsx
Normal file
|
|
@ -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(
|
||||
<MemoryRouter initialEntries={['/app/downloads?platform=windows']} future={ROUTER_FUTURE_FLAGS}>
|
||||
<DownloadCenterPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
50
website/src/__tests__/public-launch.test.ts
Normal file
50
website/src/__tests__/public-launch.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
132
website/src/__tests__/public-marketing-pages.test.tsx
Normal file
132
website/src/__tests__/public-marketing-pages.test.tsx
Normal file
|
|
@ -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(
|
||||
<MemoryRouter future={ROUTER_FUTURE_FLAGS}>
|
||||
<DownloadPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
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(
|
||||
<MemoryRouter future={ROUTER_FUTURE_FLAGS}>
|
||||
<PricingPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
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(
|
||||
<MemoryRouter initialEntries={['/support?topic=launch-readiness']} future={ROUTER_FUTURE_FLAGS}>
|
||||
<SupportPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
28
website/src/__tests__/site-routes.test.ts
Normal file
28
website/src/__tests__/site-routes.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
48
website/src/components/ui/PublicLaunchStatus.tsx
Normal file
48
website/src/components/ui/PublicLaunchStatus.tsx
Normal file
|
|
@ -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 (
|
||||
<article className="callout">
|
||||
<p className={`status-pill${ready ? ' status-pill--success' : ''}`}>
|
||||
{ready ? 'Launch-ready posture' : 'Preview posture'}
|
||||
</p>
|
||||
<h3>{title}</h3>
|
||||
<p>
|
||||
{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(', ')}.`}
|
||||
</p>
|
||||
<ul className="list">
|
||||
{checklist.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.label}: {item.configured ? 'configured' : 'missing'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!ready ? (
|
||||
<div className="button-row top-gap">
|
||||
<Link className="button button--ghost" to={buildSupportPath('launch-readiness')}>
|
||||
Contact launch support
|
||||
</Link>
|
||||
<Link className="button button--ghost" to="/open-source-notices">
|
||||
Review notices
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="panel-grid">
|
||||
|
|
@ -190,9 +210,26 @@ export function DownloadCenterPage() {
|
|||
Complete the matching checkout or operator provisioning step, then refresh this dashboard.
|
||||
</p>
|
||||
) : null}
|
||||
{requestedPlatformLabel ? (
|
||||
<div className="callout top-gap">
|
||||
<p className="status-pill status-pill--info">Requested target</p>
|
||||
<p>
|
||||
The browser handoff preserved your {requestedPlatformLabel} selection so you land on the matching protected release lane first.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="card-grid">
|
||||
{downloadTargets.map((target) => (
|
||||
<article key={target.platform} className="card card--compact">
|
||||
{orderedTargets.map((target) => (
|
||||
<article
|
||||
key={target.platform}
|
||||
className={`card card--compact${target.platformKey === requestedPlatform ? ' card--selected' : ''}`}
|
||||
>
|
||||
<div className="status-row">
|
||||
{target.platformKey === requestedPlatform ? (
|
||||
<p className="status-pill status-pill--info">Requested target</p>
|
||||
) : null}
|
||||
<p className="status-pill">{target.subtitle}</p>
|
||||
</div>
|
||||
<h3>{target.platform}</h3>
|
||||
<p>{target.details}</p>
|
||||
{target.href && canDownload ? (
|
||||
|
|
|
|||
|
|
@ -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<string, { title: string; description: string }> = {
|
||||
'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 (
|
||||
<a className="button button--primary button--full" href={href} target="_blank" rel="noreferrer">
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link className="button button--primary button--full" to={href}>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeLanding() {
|
||||
return (
|
||||
<MarketingShell
|
||||
|
|
@ -309,12 +342,25 @@ export function DocsPage() {
|
|||
}
|
||||
|
||||
export function SupportPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const supportTopic = searchParams.get('topic')
|
||||
const selectedSupportTopic = supportTopic ? supportTopicGuidance[supportTopic] : null
|
||||
|
||||
return (
|
||||
<MarketingShell
|
||||
eyebrow="Operator help"
|
||||
title="Support for rollout, downloads, pricing, and browser-to-desktop access."
|
||||
lede="Support is focused on helping operators understand what the browser shell does, what the desktop build does, and how the two connect."
|
||||
>
|
||||
{selectedSupportTopic ? (
|
||||
<Section title="Selected help lane">
|
||||
<article className="callout">
|
||||
<p className="status-pill status-pill--info">{selectedSupportTopic.title}</p>
|
||||
<p>{selectedSupportTopic.description}</p>
|
||||
</article>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Section title="Contact">
|
||||
<article className="card">
|
||||
<p>
|
||||
|
|
@ -386,14 +432,16 @@ export function PricingPage() {
|
|||
<li key={feature}>{feature}</li>
|
||||
))}
|
||||
</ul>
|
||||
<a className="button button--primary button--full" href={plan.ctaHref}>
|
||||
{plan.ctaLabel}
|
||||
</a>
|
||||
<PlanActionLink href={plan.ctaHref} label={plan.ctaLabel} />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Launch posture">
|
||||
<PublicLaunchStatus />
|
||||
</Section>
|
||||
|
||||
<Section title="Important launch note">
|
||||
<article className="callout">
|
||||
<p>
|
||||
|
|
@ -421,7 +469,7 @@ export function DownloadPage() {
|
|||
<p className="status-pill">{target.subtitle}</p>
|
||||
<p>{target.details}</p>
|
||||
{target.href ? (
|
||||
<Link className="button button--primary button--full" to="/app/downloads">
|
||||
<Link className="button button--primary button--full" to={buildProtectedDownloadPath(target.platformKey)}>
|
||||
Sign in for {target.platform} access
|
||||
</Link>
|
||||
) : (
|
||||
|
|
@ -434,6 +482,10 @@ export function DownloadPage() {
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Release posture">
|
||||
<PublicLaunchStatus title="Desktop release access stays launch-honest" />
|
||||
</Section>
|
||||
|
||||
<Section title="Why this page does not expose raw download URLs">
|
||||
<article className="callout">
|
||||
<p>
|
||||
|
|
@ -513,6 +565,10 @@ export function OpenSourceNoticesPage() {
|
|||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Distribution readiness">
|
||||
<PublicLaunchStatus title="Notices and corresponding-source readiness" />
|
||||
</Section>
|
||||
</MarketingShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
64
website/src/public-launch.ts
Normal file
64
website/src/public-launch.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
import type { DownloadPlatformKey } from './site-routes'
|
||||
import { buildSupportPath } from './site-routes'
|
||||
|
||||
const env = import.meta.env as Record<string, string | undefined>
|
||||
|
||||
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')
|
||||
|
|
|
|||
48
website/src/site-routes.ts
Normal file
48
website/src/site-routes.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
export type DownloadPlatformKey = 'windows' | 'macos' | 'linux'
|
||||
|
||||
const DOWNLOAD_PLATFORM_LABELS: Record<DownloadPlatformKey, string> = {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue