diff --git a/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_CONTENT_AND_OPERATOR_MANUAL_PACKET_2026-06-22.md b/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_CONTENT_AND_OPERATOR_MANUAL_PACKET_2026-06-22.md index 6ebe93b..e167466 100644 --- a/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_CONTENT_AND_OPERATOR_MANUAL_PACKET_2026-06-22.md +++ b/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_CONTENT_AND_OPERATOR_MANUAL_PACKET_2026-06-22.md @@ -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 diff --git a/website/README.md b/website/README.md index 1c3f25c..63a40f2 100644 --- a/website/README.md +++ b/website/README.md @@ -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 diff --git a/website/index.html b/website/index.html index b569ab7..80bf83a 100644 --- a/website/index.html +++ b/website/index.html @@ -36,6 +36,54 @@
+ diff --git a/website/src/App.tsx b/website/src/App.tsx index 6be5100..216cc9c 100644 --- a/website/src/App.tsx +++ b/website/src/App.tsx @@ -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 {children} } @@ -29,6 +55,7 @@ export default function App() { return ( + diff --git a/website/src/__tests__/App.bootstrap.test.tsx b/website/src/__tests__/App.bootstrap.test.tsx index 59b5c7a..9d3208e 100644 --- a/website/src/__tests__/App.bootstrap.test.tsx +++ b/website/src/__tests__/App.bootstrap.test.tsx @@ -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() + 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() + 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() + + 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() + expect(await screen.findByText('Pricing that matches the actual delivery model.')).toBeTruthy() expect(screen.getByTestId('supertokens-wrapper')).toBeTruthy() }) diff --git a/website/src/__tests__/auth-shell-backdrop.test.ts b/website/src/__tests__/auth-shell-backdrop.test.ts new file mode 100644 index 0000000..09e68c9 --- /dev/null +++ b/website/src/__tests__/auth-shell-backdrop.test.ts @@ -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 = '
' + }) + + 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) + }) +}) diff --git a/website/src/auth/auth-shell-backdrop.ts b/website/src/auth/auth-shell-backdrop.ts new file mode 100644 index 0000000..4d65c4c --- /dev/null +++ b/website/src/auth/auth-shell-backdrop.ts @@ -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) +} diff --git a/website/src/router/AuthShellBackdropSync.tsx b/website/src/router/AuthShellBackdropSync.tsx new file mode 100644 index 0000000..82c5c61 --- /dev/null +++ b/website/src/router/AuthShellBackdropSync.tsx @@ -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 +}