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