Prove website production deployment posture

This commit is contained in:
axiomlogicnexus 2026-06-22 03:34:04 +00:00
parent d11bf59a70
commit e4fcff160b
20 changed files with 983 additions and 15 deletions

View file

@ -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

View file

@ -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`

View file

@ -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. |

View file

@ -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

View file

@ -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

View 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.')
})
})

View file

@ -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`

View 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)
}
})
})

View 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')
})
})

View file

@ -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()
})
})

View 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)
})
})

View 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()
})
})

View 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)
})
})

View 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>
)
}

View file

@ -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 ? (

View file

@ -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>
)
}

View 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
}

View file

@ -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')

View 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)
}

View file

@ -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;