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 649e58b..0d13082 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 @@ -162,6 +162,7 @@ for `hypertwist.app` when a built `website/dist/index.html` is present: - 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 - the runtime-readiness verifier now warns when same-origin public posture leaves that static-serving mode implicit +- request-level server coverage now also proves the live route behavior for public/app routes versus `/api/*`, `/auth*`, `/health`, and missing asset paths This is browser-based user access for the operator/account surface. 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 2d49ab3..27f8164 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, 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. | +| 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. Request-level server coverage now also proves that public/app shell delivery does not shadow `/api/*`, `/auth*`, `/health`, or missing asset paths. | | 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, safe `next`-path normalization across auth entry points, 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 c2bcc5c..c013a9c 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md @@ -226,7 +226,9 @@ Current consolidated milestone snapshot: `website/dist` bundle with bounded SPA fallback for same-origin `hypertwist.app` deployment when that build output is present, while the env templates and runtime-readiness verifier now also make that static-serving posture explicit - instead of leaving it implicit, + instead of leaving it implicit, and request-level server coverage now proves + that public/app routes stay served without shadowing `/api/*`, `/auth*`, or + `/health`, 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 aade219..a40475a 100644 --- a/website/README.md +++ b/website/README.md @@ -83,6 +83,7 @@ Use the runtime-readiness command before public launch or deployment approval: - 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 - it now warns when same-origin public deployment leaves static website serving mode ambiguous +- request-level server tests now also pin that same-origin shell behavior instead of relying only on helper-level assertions The repo bootstrap CI now also validates this lane through: diff --git a/website/server/README.md b/website/server/README.md index e503d21..3e21717 100644 --- a/website/server/README.md +++ b/website/server/README.md @@ -82,6 +82,7 @@ The server now also supports a bounded first-party same-origin deployment mode: - `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 example env files now carry those static-serving controls directly so deployment posture is not implicit +- request-level tests now also prove that public/app routes serve the shell while `/api/*`, `/auth*`, `/health`, and missing asset paths remain unshadowed The website package now also ships a deploy-time verification command: diff --git a/website/server/src/__tests__/static-site-routes.test.ts b/website/server/src/__tests__/static-site-routes.test.ts new file mode 100644 index 0000000..ce6d65e --- /dev/null +++ b/website/server/src/__tests__/static-site-routes.test.ts @@ -0,0 +1,131 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import os from 'node:os' +import path from 'node:path' +import express from 'express' +import { afterEach, describe, expect, it } from 'vitest' +import { registerStaticWebsiteRoutes } from '../static-site-routes' +import type { StaticWebsiteConfig } from '../static-site' + +const tempRoots: string[] = [] + +function makeTempRoot() { + const root = mkdtempSync(path.join(os.tmpdir(), 'hypertwist-static-routes-')) + tempRoots.push(root) + return root +} + +function createStaticWebsiteConfig(root: string): StaticWebsiteConfig { + const distPath = path.join(root, 'dist') + mkdirSync(path.join(distPath, 'assets'), { recursive: true }) + writeFileSync( + path.join(distPath, 'index.html'), + '
HyperTwist static shell
', + 'utf8', + ) + writeFileSync(path.join(distPath, 'assets', 'app.js'), 'console.log("ready")', 'utf8') + + return { + enabled: true, + distPath, + indexHtmlPath: path.join(distPath, 'index.html'), + reason: 'forced_found', + } +} + +async function withServer( + configure: (app: express.Express) => void | Promise, + run: (baseUrl: string) => Promise, +) { + const app = express() + await configure(app) + + app.use((req, res) => { + res.status(404).json({ ok: false, path: req.path }) + }) + + const server = createServer(app) + await new Promise((resolve) => server.listen(0, resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + server.close() + throw new Error('Failed to bind test server') + } + + const baseUrl = `http://127.0.0.1:${address.port}` + + try { + await run(baseUrl) + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + } +} + +afterEach(() => { + while (tempRoots.length > 0) { + const root = tempRoots.pop() + if (root) { + rmSync(root, { recursive: true, force: true }) + } + } +}) + +describe('registerStaticWebsiteRoutes', () => { + it('serves the built shell for public and app routes without shadowing health, api, auth, or missing assets', async () => { + const root = makeTempRoot() + const staticWebsiteConfig = createStaticWebsiteConfig(root) + + await withServer( + (app) => { + app.get('/health', (_req, res) => { + res.json({ ok: true, service: 'hypertwist-auth-server' }) + }) + app.get('/api/auth/health', (_req, res) => { + res.json({ ok: true, service: 'hypertwist-auth-server', runtime: { mode: 'public' } }) + }) + app.get('/auth', (_req, res) => { + res.status(404).json({ ok: false, route: 'auth-root' }) + }) + + registerStaticWebsiteRoutes(app, staticWebsiteConfig) + }, + async (baseUrl) => { + const aboutResponse = await fetch(`${baseUrl}/about`) + expect(aboutResponse.status).toBe(200) + expect(await aboutResponse.text()).toContain('HyperTwist static shell') + + const downloadsResponse = await fetch(`${baseUrl}/app/downloads`) + expect(downloadsResponse.status).toBe(200) + expect(await downloadsResponse.text()).toContain('HyperTwist static shell') + + const healthResponse = await fetch(`${baseUrl}/health`) + expect(healthResponse.status).toBe(200) + expect(await healthResponse.json()).toEqual({ ok: true, service: 'hypertwist-auth-server' }) + + const authHealthResponse = await fetch(`${baseUrl}/api/auth/health`) + expect(authHealthResponse.status).toBe(200) + expect(await authHealthResponse.json()).toEqual({ + ok: true, + service: 'hypertwist-auth-server', + runtime: { mode: 'public' }, + }) + + const authRootResponse = await fetch(`${baseUrl}/auth`) + expect(authRootResponse.status).toBe(404) + expect(await authRootResponse.json()).toEqual({ ok: false, route: 'auth-root' }) + + const assetResponse = await fetch(`${baseUrl}/assets/missing.js`) + expect(assetResponse.status).toBe(404) + expect(await assetResponse.json()).toEqual({ ok: false, path: '/assets/missing.js' }) + }, + ) + }) +}) diff --git a/website/server/src/index.ts b/website/server/src/index.ts index 16bb5a3..58cf575 100644 --- a/website/server/src/index.ts +++ b/website/server/src/index.ts @@ -15,7 +15,8 @@ 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' +import { resolveStaticWebsiteConfig } from './static-site' +import { registerStaticWebsiteRoutes } from './static-site-routes' 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 @@ -422,22 +423,7 @@ 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) - }) -} +registerStaticWebsiteRoutes(app, staticWebsiteConfig) app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { console.error('[hypertwist-auth-server] unhandled error', error) diff --git a/website/server/src/static-site-routes.ts b/website/server/src/static-site-routes.ts new file mode 100644 index 0000000..89879ea --- /dev/null +++ b/website/server/src/static-site-routes.ts @@ -0,0 +1,23 @@ +import express, { type Express } from 'express' +import type { StaticWebsiteConfig } from './static-site' +import { shouldServeSpaFallback } from './static-site' + +export function registerStaticWebsiteRoutes(app: Express, staticWebsiteConfig: StaticWebsiteConfig) { + if (!staticWebsiteConfig.enabled) { + return + } + + 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) + }) +}