Harden website auth bootstrap and dashboard coverage

This commit is contained in:
axiomlogicnexus 2026-06-22 02:50:15 +00:00
parent e9546f2a95
commit 5f0f487b61
10 changed files with 364 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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(
<QueryClientProvider client={queryClient}>
<DashboardOverviewPage />
</QueryClientProvider>,
)
}
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()
})
})

View file

@ -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 (
<div>
<div data-testid="loading">{String(isLoading)}</div>
<div data-testid="user-id">{user?.id ?? 'none'}</div>
<div data-testid="auth-method">{user?.authMethod ?? 'none'}</div>
<div data-testid="plan">{user?.plan ?? 'none'}</div>
<div data-testid="role">{user?.role ?? 'none'}</div>
<div data-testid="can-download">{String(user?.canDownload ?? false)}</div>
<div data-testid="billing-source">{user?.billing?.source ?? 'none'}</div>
</div>
)
}
function renderWithProvider(children: ReactNode) {
return render(<PlatformAuthProvider>{children}</PlatformAuthProvider>)
}
function createJsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
}
}
function seedStoredUser(user: Record<string, unknown>) {
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(<AuthProbe />)
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(<AuthProbe />)
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()
})
})

View file

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

View file

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

View file

@ -0,0 +1,4 @@
export function buildLoginRedirectTarget(location: Pick<Location, 'pathname' | 'search' | 'hash'>) {
const next = `${location.pathname}${location.search}${location.hash}`
return `/login?next=${encodeURIComponent(next)}`
}

View file

@ -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 <Navigate to={`/login?next=${encodeURIComponent(location.pathname + location.search)}`} replace />
return <Navigate to={buildLoginRedirectTarget(location)} replace />
}
return <Outlet />