Harden website auth shell routing
This commit is contained in:
parent
c8cb1bfb8d
commit
e13c4bc4c5
8 changed files with 331 additions and 5 deletions
|
|
@ -258,6 +258,44 @@ Latest same-family public-manual follow-up on `2026-06-24`:
|
|||
- embedded simulator browser shell
|
||||
- native Unreal desktop runtime
|
||||
- optional full-browser simulator branch
|
||||
|
||||
Latest auth-shell hardening follow-up later on `2026-06-24`:
|
||||
|
||||
- the same public/protected website lane now also carries the sibling-site
|
||||
auth-shell posture more faithfully instead of only reusing auth APIs and
|
||||
route pages
|
||||
- HyperTwist now has its own first-party auth-shell backdrop ownership for:
|
||||
- `/login`
|
||||
- `/register`
|
||||
- `/auth/*`
|
||||
- that backdrop is now present through both:
|
||||
- a runtime route-sync layer inside the React app
|
||||
- a first-paint HTML fallback in `website/index.html`
|
||||
- the `SuperTokensWrapper` posture is now route-aware rather than global:
|
||||
- anonymous brochure routes no longer pay for the full auth shell merely
|
||||
because auth is configured
|
||||
- protected app routes, auth routes, and stored-session continuity still do
|
||||
- this keeps HyperTwist closer to the intended FamiliarOS/ScriptoriumAI
|
||||
structure while remaining truthful to HyperTwist's desktop-first product
|
||||
stance
|
||||
- focused auth/bootstrap validation stayed green under:
|
||||
- `npm --prefix website test -- --run src/__tests__/App.bootstrap.test.tsx src/__tests__/auth-shell-backdrop.test.ts src/__tests__/public-auth-pages.test.tsx src/__tests__/route-shells.test.tsx`
|
||||
- `4` test files passed
|
||||
- `21` tests passed
|
||||
- the broader owned web-surface umbrella also stayed green again under:
|
||||
- `scripts/run-hypertwist-web-surface-validation.sh`
|
||||
- focused website route/auth/release suite: `12` files, `62` tests passed
|
||||
- website/server suite: `10` files, `36` tests passed
|
||||
- website plus `Content/Browser` production audits: `found 0 vulnerabilities`
|
||||
- website/server retained only the already-documented upstream
|
||||
`supertokens-node -> nodemailer` residual
|
||||
- current truthful product reading after this follow-up:
|
||||
- the public website and protected dashboard still stay complementary to the
|
||||
desktop runtime rather than pretending to replace it
|
||||
- the shared auth lineage is now not only conceptual but also structural at
|
||||
the bootstrap/shell layer
|
||||
- the next public/web packets can focus on higher-order product polish or
|
||||
deployment truth rather than missing auth-shell parity
|
||||
- the public `/changelog` lane now also carries an explicit release-rollout
|
||||
checklist so release notes remain tied to package proof, protected download
|
||||
posture, and notices or corresponding-source follow-through instead of
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ First-party `hypertwist.app` surface for HyperTwist:
|
|||
- browser-facing operator/account dashboard
|
||||
- protected browser-access, account, and notices routes backed by live auth-health and release-manifest authority
|
||||
- shared SuperTokens auth posture reused from the FamiliarOS and ScriptoriumAI website lane
|
||||
- route-aware SuperTokens wrapping so public brochure routes do not pay for auth chrome unnecessarily while `/app`, `/login`, `/register`, `/auth/*`, and stored-session continuity still do
|
||||
- FamiliarOS-style first-party auth-shell backdrop for login/register/auth callback posture, including first-paint HTML fallback plus route-synced runtime ownership
|
||||
- desktop download posture and desktop-link handshake endpoints
|
||||
- server-backed release-manifest authority shared by public and protected download surfaces
|
||||
- Paddle-ready pricing/check-out wiring
|
||||
|
|
@ -61,6 +63,10 @@ manual:
|
|||
- the protected app shell now also carries richer operator-facing browser
|
||||
boundary, account, entitlement, and notices guidance instead of treating
|
||||
those routes as thin placeholders beside the main dashboard
|
||||
- the auth shell now also behaves more like the sibling first-party sites at
|
||||
the route/bootstrap layer: auth-specific chrome appears on login/register
|
||||
posture without wrapping every anonymous public route in the same session
|
||||
shell
|
||||
|
||||
## Local development
|
||||
|
||||
|
|
@ -115,6 +121,35 @@ and supports `--strict-auth-server-audit` when that residual should block.
|
|||
It now also checks that the website-facing Windows packaged-validation summary
|
||||
is fresh against the checked-in authoritative higher-dimensional package report.
|
||||
|
||||
Latest auth-shell hardening follow-up on `2026-06-24`:
|
||||
|
||||
- the website now carries a first-party HyperTwist auth-shell backdrop module
|
||||
and route sync layer derived from the FamiliarOS/ScriptoriumAI auth shell
|
||||
posture, but adapted to HyperTwist truth:
|
||||
- `/login`
|
||||
- `/register`
|
||||
- `/auth/*`
|
||||
- `App.tsx` no longer wraps the entire website in `SuperTokensWrapper` just
|
||||
because auth is configured; it now wraps only:
|
||||
- `/app*`
|
||||
- `/login*`
|
||||
- `/register*`
|
||||
- `/auth/*`
|
||||
- or public routes when a stored local platform session already exists
|
||||
- `website/index.html` now also carries a first-paint auth-backdrop fallback
|
||||
so auth-shell chrome is present before React route effects settle
|
||||
- focused website auth/bootstrap validation stayed green under:
|
||||
- `npm --prefix website test -- --run src/__tests__/App.bootstrap.test.tsx src/__tests__/auth-shell-backdrop.test.ts src/__tests__/public-auth-pages.test.tsx src/__tests__/route-shells.test.tsx`
|
||||
- `4` test files passed
|
||||
- `21` tests passed
|
||||
- the broader owned web-surface umbrella then stayed green again under:
|
||||
- `scripts/run-hypertwist-web-surface-validation.sh`
|
||||
- focused website route/auth/release suite: `12` files, `62` tests passed
|
||||
- website/server suite: `10` files, `36` tests passed
|
||||
- website and `Content/Browser` production audits: `found 0 vulnerabilities`
|
||||
- website/server retained only the already-documented upstream
|
||||
`supertokens-node -> nodemailer` residual
|
||||
|
||||
Current dependency-health truth from the `2026-06-23` hardening pass:
|
||||
|
||||
- `website/` production audit is clean
|
||||
|
|
|
|||
|
|
@ -36,6 +36,54 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
(function () {
|
||||
const BACKDROP_ID = 'hypertwist-auth-shell-backdrop';
|
||||
|
||||
function isAuthRoute(pathname) {
|
||||
return pathname === '/login'
|
||||
|| pathname.startsWith('/login/')
|
||||
|| pathname === '/register'
|
||||
|| pathname.startsWith('/register/')
|
||||
|| pathname.startsWith('/auth/');
|
||||
}
|
||||
|
||||
function syncAuthChrome() {
|
||||
const shouldRender = isAuthRoute(window.location.pathname);
|
||||
const existingBackdrop = document.getElementById(BACKDROP_ID);
|
||||
|
||||
if (!shouldRender) {
|
||||
if (existingBackdrop) existingBackdrop.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existingBackdrop) {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.id = BACKDROP_ID;
|
||||
backdrop.setAttribute('data-testid', 'supertokens-auth-backdrop');
|
||||
backdrop.setAttribute('aria-hidden', 'true');
|
||||
backdrop.style.position = 'fixed';
|
||||
backdrop.style.inset = '0';
|
||||
backdrop.style.zIndex = '0';
|
||||
backdrop.style.pointerEvents = 'none';
|
||||
backdrop.style.background = 'radial-gradient(circle at top, rgba(216,203,175,0.12), transparent 40%), linear-gradient(180deg, rgba(18,20,24,0.97), rgba(10,12,17,0.99))';
|
||||
document.body.appendChild(backdrop);
|
||||
}
|
||||
}
|
||||
|
||||
function startAuthChromeObserver() {
|
||||
syncAuthChrome();
|
||||
window.addEventListener('popstate', syncAuthChrome);
|
||||
window.addEventListener('hashchange', syncAuthChrome);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', startAuthChromeObserver, { once: true });
|
||||
} else {
|
||||
startAuthChromeObserver();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { BrowserRouter, useLocation } from 'react-router-dom'
|
||||
import { SuperTokensWrapper } from 'supertokens-auth-react'
|
||||
import { PlatformAuthProvider } from './auth/platform-auth'
|
||||
import { isSuperTokensConfigured } from './auth/supertokens-client'
|
||||
import { AppRouteTree } from './router/AppRouteTree'
|
||||
import { AuthShellBackdropSync } from './router/AuthShellBackdropSync'
|
||||
import { ROUTER_FUTURE_FLAGS } from './router/router-future'
|
||||
import './styles/global.css'
|
||||
|
||||
|
|
@ -17,11 +18,36 @@ const queryClient = new QueryClient({
|
|||
},
|
||||
})
|
||||
|
||||
function routeRequiresAuthShell(pathname: string) {
|
||||
return pathname.startsWith('/app')
|
||||
|| pathname === '/login'
|
||||
|| pathname.startsWith('/login/')
|
||||
|| pathname === '/register'
|
||||
|| pathname.startsWith('/register/')
|
||||
|| pathname.startsWith('/auth/')
|
||||
}
|
||||
|
||||
function hasStoredPlatformUser() {
|
||||
if (typeof window === 'undefined') return false
|
||||
try {
|
||||
return Boolean(window.localStorage.getItem('hypertwist.platform.user.v1'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function MaybeSuperTokensWrapper({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation()
|
||||
|
||||
if (!isSuperTokensConfigured()) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const needsWrapper = routeRequiresAuthShell(location.pathname) || hasStoredPlatformUser()
|
||||
if (!needsWrapper) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return <SuperTokensWrapper>{children}</SuperTokensWrapper>
|
||||
}
|
||||
|
||||
|
|
@ -29,6 +55,7 @@ export default function App() {
|
|||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter future={ROUTER_FUTURE_FLAGS}>
|
||||
<AuthShellBackdropSync />
|
||||
<MaybeSuperTokensWrapper>
|
||||
<PlatformAuthProvider>
|
||||
<AppRouteTree />
|
||||
|
|
|
|||
|
|
@ -9,9 +9,21 @@ vi.mock('../auth/platform-auth', () => ({
|
|||
usePlatformAuth: () => mockUsePlatformAuth(),
|
||||
}))
|
||||
|
||||
vi.mock('../auth/supertokens-client', () => ({
|
||||
isSuperTokensConfigured: () => mockIsSuperTokensConfigured(),
|
||||
}))
|
||||
vi.mock('../auth/supertokens-client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../auth/supertokens-client')>()
|
||||
return {
|
||||
...actual,
|
||||
isSuperTokensConfigured: () => mockIsSuperTokensConfigured(),
|
||||
isGoogleOAuthEnabled: () => false,
|
||||
isGitHubOAuthEnabled: () => false,
|
||||
isOrcidOAuthEnabled: () => false,
|
||||
getSuperTokensAuthRuntimeValidation: () => ({
|
||||
ready: true,
|
||||
missing: [],
|
||||
warnings: [],
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('supertokens-auth-react', () => ({
|
||||
SuperTokensWrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
|
|
@ -24,6 +36,7 @@ import App from '../App'
|
|||
describe('App bootstrap', () => {
|
||||
beforeEach(() => {
|
||||
cleanup()
|
||||
window.localStorage.clear()
|
||||
mockUsePlatformAuth.mockReset()
|
||||
mockIsSuperTokensConfigured.mockReset()
|
||||
mockIsSuperTokensConfigured.mockReturnValue(false)
|
||||
|
|
@ -53,7 +66,7 @@ describe('App bootstrap', () => {
|
|||
expect(screen.queryByTestId('supertokens-wrapper')).toBeNull()
|
||||
})
|
||||
|
||||
it('wraps the app in SuperTokens when configured and still renders the real public route', async () => {
|
||||
it('does not wrap ordinary public routes in SuperTokens just because auth is configured', async () => {
|
||||
mockIsSuperTokensConfigured.mockReturnValue(true)
|
||||
mockUsePlatformAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
|
|
@ -70,6 +83,63 @@ describe('App bootstrap', () => {
|
|||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByText('Pricing that matches the actual delivery model.')).toBeTruthy()
|
||||
expect(screen.queryByTestId('supertokens-wrapper')).toBeNull()
|
||||
expect(document.querySelector('[data-testid="supertokens-auth-backdrop"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('wraps auth-shell routes in SuperTokens and syncs the auth backdrop', async () => {
|
||||
mockIsSuperTokensConfigured.mockReturnValue(true)
|
||||
mockUsePlatformAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
toggleColorMode: vi.fn(),
|
||||
colorMode: 'dark',
|
||||
superTokensConfigured: true,
|
||||
user: null,
|
||||
})
|
||||
window.history.replaceState({}, '', '/login')
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByText('Log in to HyperTwist')).toBeTruthy()
|
||||
expect(screen.getByTestId('supertokens-wrapper')).toBeTruthy()
|
||||
expect(document.querySelector('[data-testid="supertokens-auth-backdrop"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the SuperTokens wrapper active on public routes when a local platform session is already stored', async () => {
|
||||
mockIsSuperTokensConfigured.mockReturnValue(true)
|
||||
mockUsePlatformAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
toggleColorMode: vi.fn(),
|
||||
colorMode: 'dark',
|
||||
superTokensConfigured: true,
|
||||
user: {
|
||||
id: 'operator-1',
|
||||
name: 'Operator',
|
||||
email: 'operator@hypertwist.app',
|
||||
authMethod: 'supertokens',
|
||||
plan: 'operator',
|
||||
},
|
||||
})
|
||||
window.localStorage.setItem('hypertwist.platform.user.v1', JSON.stringify({
|
||||
id: 'operator-1',
|
||||
name: 'Operator',
|
||||
email: 'operator@hypertwist.app',
|
||||
authMethod: 'supertokens',
|
||||
plan: 'operator',
|
||||
}))
|
||||
window.history.replaceState({}, '', '/pricing')
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByText('Pricing that matches the actual delivery model.')).toBeTruthy()
|
||||
expect(screen.getByTestId('supertokens-wrapper')).toBeTruthy()
|
||||
})
|
||||
|
|
|
|||
49
website/src/__tests__/auth-shell-backdrop.test.ts
Normal file
49
website/src/__tests__/auth-shell-backdrop.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AUTH_SHELL_BACKDROP_ID,
|
||||
ensureAuthShellBackdrop,
|
||||
isAuthShellRoute,
|
||||
syncAuthShellBackdrop,
|
||||
} from '../auth/auth-shell-backdrop'
|
||||
|
||||
describe('auth-shell-backdrop', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="root"></div>'
|
||||
})
|
||||
|
||||
it('recognizes login, register, and auth callback routes', () => {
|
||||
expect(isAuthShellRoute('/login')).toBe(true)
|
||||
expect(isAuthShellRoute('/login/reset')).toBe(true)
|
||||
expect(isAuthShellRoute('/register')).toBe(true)
|
||||
expect(isAuthShellRoute('/register/invite')).toBe(true)
|
||||
expect(isAuthShellRoute('/auth/callback')).toBe(true)
|
||||
expect(isAuthShellRoute('/app')).toBe(false)
|
||||
expect(isAuthShellRoute('/pricing')).toBe(false)
|
||||
})
|
||||
|
||||
it('creates the auth backdrop with the expected styling', () => {
|
||||
const backdrop = ensureAuthShellBackdrop()
|
||||
|
||||
expect(backdrop.id).toBe(AUTH_SHELL_BACKDROP_ID)
|
||||
expect(backdrop.getAttribute('data-testid')).toBe('supertokens-auth-backdrop')
|
||||
expect(backdrop.style.position).toBe('fixed')
|
||||
expect(backdrop.style.pointerEvents).toBe('none')
|
||||
expect(backdrop.style.zIndex).toBe('0')
|
||||
})
|
||||
|
||||
it('removes the auth backdrop when navigation leaves auth routes', () => {
|
||||
syncAuthShellBackdrop('/login')
|
||||
expect(document.getElementById(AUTH_SHELL_BACKDROP_ID)).not.toBeNull()
|
||||
|
||||
syncAuthShellBackdrop('/pricing')
|
||||
expect(document.getElementById(AUTH_SHELL_BACKDROP_ID)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not duplicate the backdrop across repeated auth-route syncs', () => {
|
||||
syncAuthShellBackdrop('/login')
|
||||
syncAuthShellBackdrop('/register')
|
||||
syncAuthShellBackdrop('/auth/callback')
|
||||
|
||||
expect(document.querySelectorAll(`#${AUTH_SHELL_BACKDROP_ID}`)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
46
website/src/auth/auth-shell-backdrop.ts
Normal file
46
website/src/auth/auth-shell-backdrop.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
export const AUTH_SHELL_BACKDROP_ID = 'hypertwist-auth-shell-backdrop'
|
||||
|
||||
function applyAuthShellBackdropStyles(backdrop: HTMLElement) {
|
||||
backdrop.id = AUTH_SHELL_BACKDROP_ID
|
||||
backdrop.setAttribute('data-testid', 'supertokens-auth-backdrop')
|
||||
backdrop.setAttribute('aria-hidden', 'true')
|
||||
backdrop.style.position = 'fixed'
|
||||
backdrop.style.inset = '0'
|
||||
backdrop.style.zIndex = '0'
|
||||
backdrop.style.pointerEvents = 'none'
|
||||
backdrop.style.background =
|
||||
'radial-gradient(circle at top, rgba(216,203,175,0.12), transparent 40%), linear-gradient(180deg, rgba(18,20,24,0.97), rgba(10,12,17,0.99))'
|
||||
}
|
||||
|
||||
export function isAuthShellRoute(pathname: string) {
|
||||
return pathname === '/login'
|
||||
|| pathname.startsWith('/login/')
|
||||
|| pathname === '/register'
|
||||
|| pathname.startsWith('/register/')
|
||||
|| pathname.startsWith('/auth/')
|
||||
}
|
||||
|
||||
export function ensureAuthShellBackdrop(doc: Document = document) {
|
||||
const existingBackdrop = doc.getElementById(AUTH_SHELL_BACKDROP_ID)
|
||||
if (existingBackdrop instanceof HTMLElement) {
|
||||
applyAuthShellBackdropStyles(existingBackdrop)
|
||||
return existingBackdrop
|
||||
}
|
||||
|
||||
const backdrop = doc.createElement('div')
|
||||
applyAuthShellBackdropStyles(backdrop)
|
||||
doc.body.appendChild(backdrop)
|
||||
return backdrop
|
||||
}
|
||||
|
||||
export function syncAuthShellBackdrop(pathname: string, doc: Document = document) {
|
||||
const shouldRender = isAuthShellRoute(pathname)
|
||||
const existingBackdrop = doc.getElementById(AUTH_SHELL_BACKDROP_ID)
|
||||
|
||||
if (!shouldRender) {
|
||||
existingBackdrop?.remove()
|
||||
return
|
||||
}
|
||||
|
||||
ensureAuthShellBackdrop(doc)
|
||||
}
|
||||
13
website/src/router/AuthShellBackdropSync.tsx
Normal file
13
website/src/router/AuthShellBackdropSync.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { useLayoutEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { syncAuthShellBackdrop } from '../auth/auth-shell-backdrop'
|
||||
|
||||
export function AuthShellBackdropSync() {
|
||||
const location = useLocation()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncAuthShellBackdrop(location.pathname)
|
||||
}, [location.pathname])
|
||||
|
||||
return null
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue