Serve same-origin website bundle from auth server
This commit is contained in:
parent
5f0f487b61
commit
34e973b4eb
9 changed files with 214 additions and 2 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
82
website/server/src/__tests__/static-site.test.ts
Normal file
82
website/server/src/__tests__/static-site.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
76
website/server/src/static-site.ts
Normal file
76
website/server/src/static-site.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue