Serve same-origin website bundle from auth server

This commit is contained in:
axiomlogicnexus 2026-06-22 02:53:23 +00:00
parent 5f0f487b61
commit 34e973b4eb
9 changed files with 214 additions and 2 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<typeof GithubProvider>
const Google = GoogleProvider as unknown as (options: { clientId: string; clientSecret: string; scope?: string[] }) => ReturnType<typeof GoogleProvider>
@ -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)
}

View file

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