From 34e973b4eb074c2ee65f53a9053c6c4540632cf5 Mon Sep 17 00:00:00 2001 From: axiomlogicnexus Date: Mon, 22 Jun 2026 02:53:23 +0000 Subject: [PATCH] Serve same-origin website bundle from auth server --- ...LING_AND_DISTRIBUTION_PACKET_2026-06-22.md | 7 ++ ..._RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md | 8 ++ .../HyperTwist/FEATURE_REGISTRY.md | 2 +- .../HyperTwist/ROADMAP.md | 4 +- website/README.md | 1 + website/server/README.md | 7 ++ .../server/src/__tests__/static-site.test.ts | 82 +++++++++++++++++++ website/server/src/index.ts | 29 +++++++ website/server/src/static-site.ts | 76 +++++++++++++++++ 9 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 website/server/src/__tests__/static-site.test.ts create mode 100644 website/server/src/static-site.ts 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 7b6d7ce..0ca20a3 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 @@ -154,6 +154,13 @@ The frontend behavior coverage now also explicitly pins: - browser auth-bootstrap normalization when the account payload reports email/fallback posture - dashboard launch-readiness visibility plus generated desktop-link verify URL behavior +The first-party auth server now also supports bounded same-origin public serving +for `hypertwist.app` when a built `website/dist/index.html` is present: + +- static delivery auto-enables from `website/server -> ../dist` +- SPA fallback remains limited to public/app routes and excludes `/api/*`, `/auth*`, and `/health` +- `WEBSITE_DIST_PATH` and `SERVE_STATIC_WEBSITE` now expose explicit deployment control for that lane + 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/ops/HYPERTWIST_WEBSITE_RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md b/docs/ops/HYPERTWIST_WEBSITE_RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md index a0e91f5..88f02bc 100644 --- a/docs/ops/HYPERTWIST_WEBSITE_RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md +++ b/docs/ops/HYPERTWIST_WEBSITE_RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md @@ -66,6 +66,14 @@ That means same-origin public deployment is the least ambiguous posture for: - public download gating - billing webhook and entitlement reflection +The current first-party server can now also own that same-origin shell directly +when a built website bundle exists: + +- default bundle pickup: `website/server -> ../dist` +- override with `WEBSITE_DIST_PATH` when deployment layout differs +- set `SERVE_STATIC_WEBSITE=false` to keep api-only mode explicit +- set `SERVE_STATIC_WEBSITE=true` to require bundle presence instead of silently staying api-only + ## If a split-host auth topology is chosen later That is allowed, but it should not be the unexamined default. 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 b845885..e815d37 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md @@ -263,7 +263,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. | +| 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, bootstrap CI now validates both the frontend and auth-server website commands directly, and the auth server can now auto-serve the built `website/dist` bundle with bounded SPA fallback for same-origin public deployment. | | 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. | diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md index b8098a7..73998c2 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md @@ -221,7 +221,9 @@ Current consolidated milestone snapshot: 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, + verify-url behavior, and the auth server can now auto-serve the built + `website/dist` bundle with bounded SPA fallback for same-origin `hypertwist.app` + deployment when that build output is present, 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 3356b0d..d6583b4 100644 --- a/website/README.md +++ b/website/README.md @@ -81,6 +81,7 @@ Use the runtime-readiness command before public launch or deployment approval: - it fails if required public launch values are still missing - it can optionally verify live `/api/auth/health` posture from the deployed site +- the auth server can now also serve the built `website/dist` bundle directly for same-origin `hypertwist.app` deployment when that build output is present The repo bootstrap CI now also validates this lane through: diff --git a/website/server/README.md b/website/server/README.md index b288bc0..d75afd7 100644 --- a/website/server/README.md +++ b/website/server/README.md @@ -75,6 +75,13 @@ That recommended same-origin production posture is recorded in: - `docs/ops/HYPERTWIST_WEBSITE_RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md` +The server now also supports a bounded first-party same-origin deployment mode: + +- if `website/dist/index.html` exists, the server auto-serves the built public site from `../dist` +- SPA fallback is limited to non-file public/app routes and does not intercept `/api/*`, `/auth*`, or `/health` +- `WEBSITE_DIST_PATH` can override the bundle location when deployment layout differs +- `SERVE_STATIC_WEBSITE=true` forces the server to expect a built bundle, while `SERVE_STATIC_WEBSITE=false` keeps api-only mode explicit + The website package now also ships a deploy-time verification command: ```bash diff --git a/website/server/src/__tests__/static-site.test.ts b/website/server/src/__tests__/static-site.test.ts new file mode 100644 index 0000000..ee5cf5e --- /dev/null +++ b/website/server/src/__tests__/static-site.test.ts @@ -0,0 +1,82 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { resolveStaticWebsiteConfig, shouldServeSpaFallback } from '../static-site' + +const tempRoots: string[] = [] + +function makeTempRoot() { + const root = mkdtempSync(path.join(os.tmpdir(), 'hypertwist-static-site-')) + tempRoots.push(root) + return root +} + +afterEach(() => { + while (tempRoots.length > 0) { + const root = tempRoots.pop() + if (root) { + rmSync(root, { recursive: true, force: true }) + } + } +}) + +describe('resolveStaticWebsiteConfig', () => { + it('auto-enables same-origin static serving when website/dist/index.html exists', () => { + const root = makeTempRoot() + const distDir = path.join(root, 'dist') + mkdirSync(distDir, { recursive: true }) + writeFileSync(path.join(distDir, 'index.html'), '', 'utf8') + + const config = resolveStaticWebsiteConfig({ + cwd: path.join(root, 'server'), + websiteDistPath: '../dist', + }) + + expect(config.enabled).toBe(true) + expect(config.reason).toBe('auto_found') + expect(config.indexHtmlPath).toBe(path.join(distDir, 'index.html')) + }) + + it('stays disabled in auto mode when no built website output is present', () => { + const root = makeTempRoot() + + const config = resolveStaticWebsiteConfig({ + cwd: path.join(root, 'server'), + websiteDistPath: '../dist', + }) + + expect(config.enabled).toBe(false) + expect(config.reason).toBe('auto_missing') + }) + + it('reports a forced-missing posture when static serving is explicitly requested without a build', () => { + const root = makeTempRoot() + + const config = resolveStaticWebsiteConfig({ + cwd: path.join(root, 'server'), + websiteDistPath: '../dist', + serveStaticWebsite: 'true', + }) + + expect(config.enabled).toBe(false) + expect(config.reason).toBe('forced_missing') + }) +}) + +describe('shouldServeSpaFallback', () => { + it('accepts application routes and marketing paths', () => { + expect(shouldServeSpaFallback('/')).toBe(true) + expect(shouldServeSpaFallback('/about')).toBe(true) + expect(shouldServeSpaFallback('/app/downloads')).toBe(true) + expect(shouldServeSpaFallback('/login')).toBe(true) + }) + + it('rejects api, auth, health, and static-asset requests', () => { + expect(shouldServeSpaFallback('/health')).toBe(false) + expect(shouldServeSpaFallback('/api/auth/me')).toBe(false) + expect(shouldServeSpaFallback('/auth/session/refresh')).toBe(false) + expect(shouldServeSpaFallback('/assets/index-abc123.js')).toBe(false) + expect(shouldServeSpaFallback('/favicon.ico')).toBe(false) + }) +}) diff --git a/website/server/src/index.ts b/website/server/src/index.ts index 0b84f3f..16bb5a3 100644 --- a/website/server/src/index.ts +++ b/website/server/src/index.ts @@ -15,6 +15,7 @@ import { createBillingStateStore, type BillingPlan, type BillingRole } from './b import { verifyPaddleWebhookSignature } from './paddle-webhook' import { getRuntimeConfigDiagnostics } from './runtime-config' import { buildAllowedOriginMatcher, createDesktopLinkStore } from './security' +import { resolveStaticWebsiteConfig, shouldServeSpaFallback } from './static-site' const Github = GithubProvider as unknown as (options: { clientId: string; clientSecret: string; scope?: string[] }) => ReturnType const Google = GoogleProvider as unknown as (options: { clientId: string; clientSecret: string; scope?: string[] }) => ReturnType @@ -42,6 +43,11 @@ const PADDLE_WEBHOOK_TOLERANCE_MS = Number(process.env.PADDLE_WEBHOOK_TOLERANCE_ const BILLING_STATE_PATH = process.env.BILLING_STATE_PATH || path.join(process.cwd(), 'data', 'hypertwist-billing-state.json') const PADDLE_PRODUCT_PLAN_MAP = parseBillingPlanMap(process.env.PADDLE_PRODUCT_PLAN_MAP) const PADDLE_PRICE_PLAN_MAP = parseBillingPlanMap(process.env.PADDLE_PRICE_PLAN_MAP) +const staticWebsiteConfig = resolveStaticWebsiteConfig({ + cwd: process.cwd(), + websiteDistPath: process.env.WEBSITE_DIST_PATH, + serveStaticWebsite: process.env.SERVE_STATIC_WEBSITE, +}) const runtimeConfigDiagnostics = getRuntimeConfigDiagnostics({ apiDomain: API_DOMAIN, websiteDomain: WEBSITE_DOMAIN, @@ -417,6 +423,22 @@ app.get('/api/auth/desktop-link/verify', (req, res) => { app.use(errorHandler()) +if (staticWebsiteConfig.enabled) { + app.use(express.static(staticWebsiteConfig.distPath, { + fallthrough: true, + index: false, + })) + + app.get('*', (req, res, next) => { + if (!shouldServeSpaFallback(req.path)) { + next() + return + } + + res.sendFile(staticWebsiteConfig.indexHtmlPath) + }) +} + app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { console.error('[hypertwist-auth-server] unhandled error', error) res.status(500).json({ error: 'internal_server_error' }) @@ -426,6 +448,13 @@ app.listen(PORT, () => { console.log(`HyperTwist auth server listening on ${API_DOMAIN}`) console.log(`Frontend origin: ${WEBSITE_DOMAIN}`) console.log(`SuperTokens core: ${SUPERTOKENS_CORE_URI}`) + if (staticWebsiteConfig.enabled) { + console.log(`[hypertwist-auth-server] serving website build from ${staticWebsiteConfig.distPath}`) + } else if (staticWebsiteConfig.reason === 'forced_missing') { + console.warn(`[hypertwist-auth-server] SERVE_STATIC_WEBSITE requested, but ${staticWebsiteConfig.indexHtmlPath} is missing`) + } else { + console.log(`[hypertwist-auth-server] no built website bundle detected at ${staticWebsiteConfig.indexHtmlPath}; api-only mode remains active`) + } if (runtimeConfigDiagnostics.errors.length > 0 || runtimeConfigDiagnostics.warnings.length > 0) { console.warn('[hypertwist-auth-server] runtime configuration diagnostics', runtimeConfigDiagnostics) } diff --git a/website/server/src/static-site.ts b/website/server/src/static-site.ts new file mode 100644 index 0000000..73fc88f --- /dev/null +++ b/website/server/src/static-site.ts @@ -0,0 +1,76 @@ +import { existsSync } from 'node:fs' +import path from 'node:path' + +function normalizeFlag(value: string | undefined) { + return String(value || '').trim().toLowerCase() +} + +function hasFileExtension(requestPath: string) { + const lastSegment = requestPath.split('/').pop() || '' + return /\.[a-z0-9]+$/i.test(lastSegment) +} + +export interface StaticWebsiteConfig { + enabled: boolean + distPath: string + indexHtmlPath: string + reason: 'auto_found' | 'auto_missing' | 'forced_found' | 'forced_missing' | 'explicit_disabled' +} + +export function resolveStaticWebsiteConfig(options?: { + cwd?: string + websiteDistPath?: string + serveStaticWebsite?: string +}): StaticWebsiteConfig { + const cwd = options?.cwd || process.cwd() + const distPath = path.resolve(cwd, options?.websiteDistPath || '../dist') + const indexHtmlPath = path.join(distPath, 'index.html') + const serveFlag = normalizeFlag(options?.serveStaticWebsite) + const hasIndexHtml = existsSync(indexHtmlPath) + + if (serveFlag === 'false' || serveFlag === '0' || serveFlag === 'no') { + return { + enabled: false, + distPath, + indexHtmlPath, + reason: 'explicit_disabled', + } + } + + if (serveFlag === 'true' || serveFlag === '1' || serveFlag === 'yes') { + return { + enabled: hasIndexHtml, + distPath, + indexHtmlPath, + reason: hasIndexHtml ? 'forced_found' : 'forced_missing', + } + } + + return { + enabled: hasIndexHtml, + distPath, + indexHtmlPath, + reason: hasIndexHtml ? 'auto_found' : 'auto_missing', + } +} + +export function shouldServeSpaFallback(requestPath: string) { + if (!requestPath || requestPath === '/health') { + return false + } + + if ( + requestPath.startsWith('/api/') + || requestPath === '/api' + || requestPath.startsWith('/auth/') + || requestPath === '/auth' + ) { + return false + } + + if (hasFileExtension(requestPath)) { + return false + } + + return true +}