diff --git a/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_AUTH_BILLING_AND_DISTRIBUTION_PACKET_2026-06-22.md b/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_AUTH_BILLING_AND_DISTRIBUTION_PACKET_2026-06-22.md
index 2cd9da2..7b6d7ce 100644
--- a/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_AUTH_BILLING_AND_DISTRIBUTION_PACKET_2026-06-22.md
+++ b/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_AUTH_BILLING_AND_DISTRIBUTION_PACKET_2026-06-22.md
@@ -148,6 +148,12 @@ The repo bootstrap CI now also validates the website lane directly through:
- `website` frontend install, type-check, test, and build
- `website/server` install, type-check, and test
+The frontend behavior coverage now also explicitly pins:
+
+- login redirect preservation for pathname, query, and hash deep links
+- browser auth-bootstrap normalization when the account payload reports email/fallback posture
+- dashboard launch-readiness visibility plus generated desktop-link verify URL behavior
+
This is browser-based user access for the operator/account surface.
It is **not** a claim that the simulator itself is now browser-owned.
diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md
index 6a77378..b845885 100644
--- a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md
+++ b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md
@@ -264,7 +264,7 @@ 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, and bootstrap CI now validates both the frontend and auth-server website commands directly. |
-| 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. |
+| 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, fallback/email auth-bootstrap normalization, 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. |
| Paddle-ready pricing and billing webhook seam | Implemented now | first-party `website/` app + `website/server` billing endpoint | The public pricing surface now exists with plan structure, checkout-link configuration seams, and the same `/api/billing/paddle/webhook` endpoint family used by the broader product website lane. The current server now verifies `Paddle-Signature` against `PADDLE_WEBHOOK_SECRET` using the documented raw-body HMAC flow, persists a bounded first-party billing state file, and applies verified Paddle events into account/download entitlement state that the browser dashboard consumes, with focused `website/server` tests green on `2026-06-22`. Production checkout URLs, secret management, and broader operator/admin billing workflows remain deployment/application tasks, not shipped-code omissions. |
| Public open-source notices and corresponding-source surface | Implemented now | first-party `website/` app + `HYPERTWIST_MPL_DISTRIBUTION_PLACEMENT_CHECKLIST_2026-05-25.md` | HyperTwist now has a stable public `Open Source Notices` route linked from pricing, download, and footer surfaces, satisfying the requirement that public distribution surfaces expose notice and corresponding-source guidance when shipped builds contain `MPL`-covered material. The exact public corresponding-source URL still must be configured before external launch. |
diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md
index a34ccea..b8098a7 100644
--- a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md
+++ b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md
@@ -218,7 +218,10 @@ Current consolidated milestone snapshot:
`/api/auth/health` posture before public launch, along with separate local
versus production example env families whose `replace-me` scaffolding is now
explicitly rejected by that verifier, and the bootstrap CI lane now also runs
- the website/frontend plus website/server validation commands directly,
+ the website/frontend plus website/server validation commands directly, while
+ focused frontend coverage now also pins deep-link login redirects, fallback
+ auth-bootstrap normalization, and dashboard launch-readiness plus desktop-link
+ verify-url behavior,
persists a bounded first-party billing-state file, applies verified Paddle
events into account/download entitlement state, and surfaces that resolved
billing/download posture back through `/api/auth/me`, the protected browser
diff --git a/website/README.md b/website/README.md
index cccddd6..3356b0d 100644
--- a/website/README.md
+++ b/website/README.md
@@ -86,3 +86,9 @@ The repo bootstrap CI now also validates this lane through:
- frontend `npm ci`, `npm run type-check`, `npm test`, and `npm run build`
- auth-server `npm ci`, `npm run type-check`, and `npm test`
+
+The focused frontend test coverage now also pins:
+
+- route-guard redirect preservation for pathname, query, and hash deep links
+- auth-bootstrap normalization when fallback/email sessions are re-hydrated
+- dashboard launch-readiness plus desktop-link verify-url behavior
diff --git a/website/src/__tests__/DashboardOverviewPage.test.tsx b/website/src/__tests__/DashboardOverviewPage.test.tsx
new file mode 100644
index 0000000..58b9700
--- /dev/null
+++ b/website/src/__tests__/DashboardOverviewPage.test.tsx
@@ -0,0 +1,130 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+
+const mockUsePlatformAuth = vi.fn()
+const mockCreateDesktopLinkToken = vi.fn()
+const mockGetAuthHealth = vi.fn()
+const mockBuildAuthApiBaseUrls = vi.fn(() => ['https://hypertwist.app'])
+
+vi.mock('../auth/platform-auth', () => ({
+ usePlatformAuth: () => mockUsePlatformAuth(),
+}))
+
+vi.mock('../auth/auth-api', () => ({
+ buildAuthApiBaseUrls: () => mockBuildAuthApiBaseUrls(),
+ createDesktopLinkToken: (...args: unknown[]) => mockCreateDesktopLinkToken(...args),
+ getAuthHealth: (...args: unknown[]) => mockGetAuthHealth(...args),
+}))
+
+import { DashboardOverviewPage } from '../pages/app-pages'
+
+function renderPage() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ })
+
+ return render(
+
+
+ ,
+ )
+}
+
+describe('DashboardOverviewPage', () => {
+ beforeEach(() => {
+ mockUsePlatformAuth.mockReset()
+ mockCreateDesktopLinkToken.mockReset()
+ mockGetAuthHealth.mockReset()
+ mockBuildAuthApiBaseUrls.mockReset()
+ mockBuildAuthApiBaseUrls.mockReturnValue(['https://hypertwist.app'])
+
+ mockUsePlatformAuth.mockReturnValue({
+ user: {
+ id: 'operator-1',
+ name: 'Operator',
+ email: 'operator@hypertwist.app',
+ authMethod: 'email',
+ plan: 'operator',
+ role: 'operator',
+ canDownload: true,
+ billing: {
+ source: 'local-fallback',
+ accessStatus: 'trial',
+ canDownload: true,
+ lastEventType: 'transaction.completed',
+ },
+ },
+ superTokensConfigured: true,
+ })
+ })
+
+ it('surfaces launch-readiness issues and the generated desktop-link verify URL', async () => {
+ mockGetAuthHealth.mockResolvedValue({
+ ok: true,
+ service: 'hypertwist-auth-server',
+ supertokens: {
+ configured: true,
+ reachable: false,
+ ready: false,
+ apiVersion: null,
+ oauth: {
+ github: false,
+ google: false,
+ },
+ },
+ fallback: {
+ enabled: true,
+ active: true,
+ reason: 'not_ready',
+ },
+ billing: {
+ statePath: '/tmp/hypertwist-billing.json',
+ processedEventCount: 0,
+ pricePlanMapConfigured: false,
+ productPlanMapConfigured: false,
+ webhookSecretConfigured: false,
+ },
+ runtime: {
+ mode: 'mixed',
+ public_origin_ready: false,
+ cookie_secure: false,
+ api_domain: 'http://localhost:3001',
+ website_domain: 'http://localhost:4273',
+ warnings: ['COOKIE_SECURE disabled'],
+ errors: ['API_DOMAIN still points at localhost'],
+ },
+ })
+
+ mockCreateDesktopLinkToken.mockResolvedValue({
+ ok: true,
+ token: 'desktop-token-123',
+ expires_at: '2026-06-22T12:00:00.000Z',
+ })
+
+ renderPage()
+
+ await waitFor(() => {
+ expect(screen.queryByText(/Checking auth server health/i)).toBeNull()
+ })
+
+ expect(screen.getByText(/This session is currently using local fallback posture/i)).toBeTruthy()
+ expect(screen.getByText(/Shared auth core is not fully ready right now/i)).toBeTruthy()
+ expect(screen.getByText(/Public launch is not fully configured yet:/i)).toBeTruthy()
+ expect(screen.getByText(/Paddle webhook secret missing/i)).toBeTruthy()
+ expect(screen.getByText(/Public auth runtime still uses local or mixed deployment posture/i)).toBeTruthy()
+
+ await userEvent.click(screen.getByRole('button', { name: /generate desktop-link token/i }))
+
+ await waitFor(() => {
+ expect(screen.getByText((content) => content === 'desktop-token-123')).toBeTruthy()
+ })
+
+ expect(screen.getByText(/https:\/\/hypertwist\.app\/api\/auth\/desktop-link\/verify\?token=desktop-token-123/i)).toBeTruthy()
+ })
+})
diff --git a/website/src/__tests__/platform-auth.bootstrap.test.tsx b/website/src/__tests__/platform-auth.bootstrap.test.tsx
new file mode 100644
index 0000000..3fa4fc8
--- /dev/null
+++ b/website/src/__tests__/platform-auth.bootstrap.test.tsx
@@ -0,0 +1,163 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, render, screen, waitFor } from '@testing-library/react'
+import type { ReactNode } from 'react'
+
+const mockGetCurrentUser = vi.fn()
+const mockLogoutCurrentUser = vi.fn()
+const mockEnsureSuperTokensInit = vi.fn()
+const mockIsSuperTokensConfigured = vi.fn(() => true)
+
+vi.mock('../auth/auth-api', () => ({
+ getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args),
+ logoutCurrentUser: (...args: unknown[]) => mockLogoutCurrentUser(...args),
+}))
+
+vi.mock('../auth/supertokens-client', () => ({
+ ensureSuperTokensInit: (...args: unknown[]) => mockEnsureSuperTokensInit(...args),
+ isSuperTokensConfigured: () => mockIsSuperTokensConfigured(),
+}))
+
+vi.mock('supertokens-auth-react/recipe/emailpassword', () => ({
+ signIn: vi.fn(),
+ signUp: vi.fn(),
+}))
+
+vi.mock('supertokens-auth-react/recipe/thirdparty', () => ({
+ redirectToThirdPartyLogin: vi.fn(),
+}))
+
+vi.mock('supertokens-auth-react/recipe/session', () => ({
+ signOut: vi.fn(),
+}))
+
+import { PlatformAuthProvider, usePlatformAuth } from '../auth/platform-auth'
+
+const AUTH_STORAGE_KEY = 'hypertwist.platform.user.v1'
+
+function AuthProbe() {
+ const { user, isLoading } = usePlatformAuth()
+
+ return (
+
+
{String(isLoading)}
+
{user?.id ?? 'none'}
+
{user?.authMethod ?? 'none'}
+
{user?.plan ?? 'none'}
+
{user?.role ?? 'none'}
+
{String(user?.canDownload ?? false)}
+
{user?.billing?.source ?? 'none'}
+
+ )
+}
+
+function renderWithProvider(children: ReactNode) {
+ return render({children})
+}
+
+function createJsonResponse(status: number, body: unknown) {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ json: async () => body,
+ }
+}
+
+function seedStoredUser(user: Record) {
+ window.localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(user))
+}
+
+describe('PlatformAuthProvider bootstrap', () => {
+ beforeEach(() => {
+ cleanup()
+ window.localStorage.clear()
+ document.documentElement.removeAttribute('data-theme')
+ mockGetCurrentUser.mockReset()
+ mockLogoutCurrentUser.mockReset()
+ mockEnsureSuperTokensInit.mockReset()
+ mockIsSuperTokensConfigured.mockReset()
+ mockIsSuperTokensConfigured.mockReturnValue(true)
+ })
+
+ it('preserves email fallback auth method when bootstrap mode is email_fallback', async () => {
+ seedStoredUser({
+ id: 'fallback-1',
+ email: 'founder@hypertwist.app',
+ name: 'Founder',
+ authMethod: 'email',
+ plan: 'free',
+ role: 'operator',
+ canDownload: false,
+ billing: {
+ source: 'local-fallback',
+ accessStatus: 'local-fallback',
+ canDownload: false,
+ },
+ })
+
+ mockGetCurrentUser.mockResolvedValue(
+ createJsonResponse(200, {
+ mode: 'email_fallback',
+ user: {
+ id: 'fallback-1',
+ email: 'founder@hypertwist.app',
+ name: 'Founder',
+ plan: 'operator',
+ role: 'operator',
+ can_download: true,
+ billing: {
+ source: 'local-fallback',
+ access_status: 'local-fallback',
+ can_download: false,
+ },
+ },
+ }),
+ )
+
+ renderWithProvider()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('plan').textContent).toBe('operator')
+ })
+
+ expect(screen.getByTestId('auth-method').textContent).toBe('email')
+ expect(screen.getByTestId('can-download').textContent).toBe('true')
+ expect(screen.getByTestId('billing-source').textContent).toBe('local-fallback')
+ expect(mockEnsureSuperTokensInit).toHaveBeenCalledTimes(1)
+
+ expect(JSON.parse(window.localStorage.getItem(AUTH_STORAGE_KEY) || '{}')).toMatchObject({
+ authMethod: 'email',
+ plan: 'operator',
+ canDownload: true,
+ })
+ })
+
+ it('keeps a stored local fallback session when bootstrap cannot reach the account API', async () => {
+ mockIsSuperTokensConfigured.mockReturnValue(false)
+ seedStoredUser({
+ id: 'local-1',
+ email: 'operator@hypertwist.app',
+ name: 'Operator',
+ authMethod: 'email',
+ plan: 'free',
+ role: 'operator',
+ canDownload: false,
+ billing: {
+ source: 'local-fallback',
+ accessStatus: 'local-fallback',
+ canDownload: false,
+ },
+ })
+
+ mockGetCurrentUser.mockRejectedValue(new Error('offline'))
+
+ renderWithProvider()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('loading').textContent).toBe('false')
+ })
+
+ expect(screen.getByTestId('auth-method').textContent).toBe('email')
+ expect(screen.getByTestId('billing-source').textContent).toBe('local-fallback')
+ expect(mockEnsureSuperTokensInit).not.toHaveBeenCalled()
+ })
+})
diff --git a/website/src/__tests__/route-guard.test.ts b/website/src/__tests__/route-guard.test.ts
new file mode 100644
index 0000000..6213f83
--- /dev/null
+++ b/website/src/__tests__/route-guard.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest'
+import { buildLoginRedirectTarget } from '../auth/route-guard'
+
+describe('route guard helpers', () => {
+ it('builds a login redirect target with encoded pathname, search, and hash', () => {
+ const target = buildLoginRedirectTarget({
+ pathname: '/app/downloads',
+ search: '?platform=windows',
+ hash: '#desktop-link',
+ })
+
+ expect(target).toBe('/login?next=%2Fapp%2Fdownloads%3Fplatform%3Dwindows%23desktop-link')
+ })
+})
diff --git a/website/src/auth/platform-auth.tsx b/website/src/auth/platform-auth.tsx
index 7b29fa2..158913a 100644
--- a/website/src/auth/platform-auth.tsx
+++ b/website/src/auth/platform-auth.tsx
@@ -94,6 +94,36 @@ function normalizePlan(value: unknown): PlatformUser['plan'] {
return 'free'
}
+function normalizeAuthMethod(value: unknown): AuthMethod | undefined {
+ const method = String(value || '').trim().toLowerCase()
+ if (
+ method === 'supertokens' ||
+ method === 'email' ||
+ method === 'github' ||
+ method === 'google' ||
+ method === 'orcid'
+ ) {
+ return method
+ }
+ return undefined
+}
+
+function deriveBootstrapAuthMethod(payload: ApiBootstrapUserPayload, fallbackMethod: AuthMethod): AuthMethod {
+ const explicitMethod = normalizeAuthMethod(payload.user?.auth_method)
+ if (explicitMethod) {
+ return explicitMethod
+ }
+
+ const mode = String(payload.mode || '').trim().toLowerCase()
+ if (mode === 'supertokens') return 'supertokens'
+ if (mode === 'github' || mode === 'google' || mode === 'orcid') return mode
+ if (mode === 'email' || mode === 'email_fallback' || mode === 'local_fallback' || mode === 'local-fallback') {
+ return 'email'
+ }
+
+ return fallbackMethod
+}
+
function normalizeRole(value: unknown): PlatformRole | undefined {
const role = String(value || '').trim().toLowerCase()
if (role === 'viewer' || role === 'operator' || role === 'reviewer' || role === 'admin') {
@@ -146,7 +176,7 @@ function normalizeApiUser(input: ApiBootstrapUserPayload['user'] | undefined, fa
id,
email,
name: String(input.name || '').trim() || deriveNameFromEmail(email),
- authMethod: String(input.auth_method || '').trim() === 'supertokens' ? 'supertokens' : fallbackMethod,
+ authMethod: normalizeAuthMethod(input.auth_method) || fallbackMethod,
plan: normalizePlan(input.plan),
role,
isAdmin: input.is_admin === true || role === 'admin',
@@ -226,7 +256,7 @@ export function PlatformAuthProvider({ children }: { children: ReactNode }) {
}
const payload = await response.json() as ApiBootstrapUserPayload
- const nextUser = normalizeApiUser(payload.user, 'supertokens')
+ const nextUser = normalizeApiUser(payload.user, deriveBootstrapAuthMethod(payload, 'supertokens'))
if (!cancelled) {
setUser(nextUser)
writeStoredUser(nextUser)
@@ -292,7 +322,7 @@ export function PlatformAuthProvider({ children }: { children: ReactNode }) {
return { ok: false, error: 'Signed in, but account bootstrap failed.' }
}
const payload = await bootstrapResponse.json() as ApiBootstrapUserPayload
- const nextUser = normalizeApiUser(payload.user, 'supertokens')
+ const nextUser = normalizeApiUser(payload.user, deriveBootstrapAuthMethod(payload, 'supertokens'))
setUser(nextUser)
writeStoredUser(nextUser)
} catch {
@@ -336,7 +366,7 @@ export function PlatformAuthProvider({ children }: { children: ReactNode }) {
return { ok: false, error: 'Account created, but bootstrap failed.' }
}
const payload = await bootstrapResponse.json() as ApiBootstrapUserPayload
- const nextUser = normalizeApiUser(payload.user, 'supertokens')
+ const nextUser = normalizeApiUser(payload.user, deriveBootstrapAuthMethod(payload, 'supertokens'))
setUser(nextUser)
writeStoredUser(nextUser)
} catch {
diff --git a/website/src/auth/route-guard.ts b/website/src/auth/route-guard.ts
new file mode 100644
index 0000000..e59500e
--- /dev/null
+++ b/website/src/auth/route-guard.ts
@@ -0,0 +1,4 @@
+export function buildLoginRedirectTarget(location: Pick) {
+ const next = `${location.pathname}${location.search}${location.hash}`
+ return `/login?next=${encodeURIComponent(next)}`
+}
diff --git a/website/src/components/routes/ProtectedRoute.tsx b/website/src/components/routes/ProtectedRoute.tsx
index 27bbfcc..50fd836 100644
--- a/website/src/components/routes/ProtectedRoute.tsx
+++ b/website/src/components/routes/ProtectedRoute.tsx
@@ -1,5 +1,6 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { usePlatformAuth } from '../../auth/platform-auth'
+import { buildLoginRedirectTarget } from '../../auth/route-guard'
import { GeneralPageLoader } from '../ui/Skeletons'
export function ProtectedRoute() {
@@ -11,7 +12,7 @@ export function ProtectedRoute() {
}
if (!isAuthenticated) {
- return
+ return
}
return