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 ef77038..850a3a9 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 @@ -92,6 +92,7 @@ Current behavior: - optional ORCID redirect posture retained client-side as a backend-gated extension point - deterministic local fallback mode when the backend is not configured - bounded auth-health probing that now distinguishes configured, reachable, and ready shared-core posture without mutating auth state +- bounded frontend auth-env diagnostics that now warn when the browser lane still points at loopback, insecure `http`, or split backend/auth targets ### Browser dashboard posture @@ -118,6 +119,11 @@ The protected dashboard now also has a first-party launch-readiness panel for: - Paddle checkout-link presence by plan - corresponding-source and open-source notices URL presence - backend webhook-secret and billing-map configuration posture +- public auth runtime posture, cookie-hardening posture, and local-vs-public runtime diagnostics + +The login and register pages now also surface those shared-auth runtime warnings +instead of silently behaving like production auth when the lane is still in +local fallback or mixed deployment posture. This is browser-based user access for the operator/account surface. @@ -188,6 +194,11 @@ Before public launch, operators still must configure: - live notification-destination secret management - any broader operator/admin billing workflow beyond the current bounded entitlement application +Recommended public runtime values and same-origin deployment posture now have a +dedicated authority note: + +- `docs/ops/HYPERTWIST_WEBSITE_RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md` + ## Validation completed Frontend validation: 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 new file mode 100644 index 0000000..67f28ff --- /dev/null +++ b/docs/ops/HYPERTWIST_WEBSITE_RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md @@ -0,0 +1,90 @@ +# HyperTwist Website Runtime Configuration Guide + +Created on `2026-06-22`. + +## Purpose + +This note records the recommended production runtime posture for the first-party +`website/` and `website/server/` lane that powers `hypertwist.app`. + +It exists because the source tree now has: + +- a real public website +- a real browser auth/dashboard surface +- explicit runtime diagnostics that can distinguish local, mixed, and public posture + +## Recommended public posture + +For the simplest honest public deployment, prefer same-origin browser and auth +posture: + +- frontend public origin: `https://hypertwist.app` +- backend public origin: `https://hypertwist.app` +- frontend route prefix: `/` +- auth API base path: `/auth` +- auth website base path: `/auth` + +Recommended server env posture: + +- `API_DOMAIN=https://hypertwist.app` +- `WEBSITE_DOMAIN=https://hypertwist.app` +- `API_BASE_PATH=/auth` +- `WEBSITE_BASE_PATH=/auth` +- `COOKIE_SECURE=true` +- real `PADDLE_WEBHOOK_SECRET` +- real `PADDLE_PRODUCT_PLAN_MAP` and/or `PADDLE_PRICE_PLAN_MAP` + +Recommended frontend env posture: + +- `VITE_SUPERTOKENS_API_DOMAIN=https://hypertwist.app` +- `VITE_SUPERTOKENS_WEBSITE_DOMAIN=https://hypertwist.app` +- `VITE_AUTH_API_BASE_URL=https://hypertwist.app` +- real `VITE_WINDOWS_DOWNLOAD_URL` +- real `VITE_PADDLE_CHECKOUT_URL_OPERATOR` +- real `VITE_PADDLE_CHECKOUT_URL_STUDIO` +- real `VITE_MPL_SOURCE_URL` +- real `VITE_OPEN_SOURCE_REPO_URL` + +## Why same-origin is the clean default + +The current first-party browser lane already supports exact-origin checks, +dashboard-side launch-readiness visibility, and same-origin auth fallback. + +That means same-origin public deployment is the least ambiguous posture for: + +- auth cookies +- protected dashboard access +- browser-to-desktop token issuance +- public download gating +- billing webhook and entitlement reflection + +## If a split-host auth topology is chosen later + +That is allowed, but it should not be the unexamined default. + +If a split-host topology is used: + +- keep all public-facing auth origins on `https` +- keep `COOKIE_SECURE=true` +- ensure `VITE_AUTH_API_BASE_URL` and `VITE_SUPERTOKENS_API_DOMAIN` still point at the same backend +- ensure dashboard health reports `public auth origin ready: yes` +- ensure launch-readiness warnings are empty before public launch + +## Current diagnostics that must be green + +Before public launch, the current runtime and dashboard surfaces should show: + +- runtime mode: `public` +- `public auth origin ready: yes` +- no runtime config errors +- no runtime config warnings that indicate loopback, insecure `http`, or partial OAuth setup +- dashboard launch-readiness issues cleared for download/check-out/source configuration + +## Related authorities + +- `website/README.md` +- `website/server/README.md` +- `docs/ops/HYPERTWIST_PUBLIC_WEBSITE_AUTH_BILLING_AND_DISTRIBUTION_PACKET_2026-06-22.md` +- `docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md` +- `docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md` +- `docs/ops/HYPERTWIST_VPS_TLS_CERTIFICATE_AND_IONOS_SEPARATION_GUIDE_2026-05-30.md` 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 4972c76..e221384 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md @@ -264,7 +264,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. | -| 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, and billing-secret/map configuration. | +| 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. | | 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. | | Public open-source notices and corresponding-source surface | Implemented now | first-party `website/` app + `HYPERTWIST_MPL_DISTRIBUTION_PLACEMENT_CHECKLIST_2026-05-25.md` | HyperTwist now has a stable public `Open Source Notices` route linked from pricing, download, and footer surfaces, satisfying the requirement that public distribution surfaces expose notice and corresponding-source guidance when shipped builds contain `MPL`-covered material. The exact public corresponding-source URL still must be configured before external launch. | diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md index 4f56023..c25c86e 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md @@ -209,7 +209,11 @@ Current consolidated milestone snapshot: fallback-active reason back into the protected dashboard rather than hardcoding readiness, and the protected dashboard now also carries a first-party launch-readiness panel for download/check-out/legal configuration - posture using the existing site-config and auth-health seams, + posture using the existing site-config and auth-health seams, while the + website/server lane now also has first-party runtime-configuration + diagnostics for local versus mixed versus public auth posture and the + login/register surfaces now expose those warnings instead of silently + resembling a production-ready lane, 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/.env.example b/website/.env.example index b868be0..c1a0e90 100644 --- a/website/.env.example +++ b/website/.env.example @@ -3,6 +3,7 @@ VITE_SUPERTOKENS_WEBSITE_DOMAIN=http://localhost:4273 VITE_SUPERTOKENS_API_BASE_PATH=/auth VITE_SUPERTOKENS_WEBSITE_BASE_PATH=/auth VITE_AUTH_API_BASE_URL=http://localhost:3001 +VITE_AUTH_API_TIMEOUT_MS=8000 VITE_GITHUB_OAUTH_ENABLED=false VITE_GOOGLE_OAUTH_ENABLED=false VITE_ORCID_OAUTH_ENABLED=false diff --git a/website/README.md b/website/README.md index 7aae4e8..89b9b71 100644 --- a/website/README.md +++ b/website/README.md @@ -8,6 +8,7 @@ First-party `hypertwist.app` surface for HyperTwist: - desktop download posture and desktop-link handshake endpoints - Paddle-ready pricing/check-out wiring - dashboard-side launch-readiness surface for download, checkout, auth, and notice configuration +- runtime diagnostics that distinguish local, mixed, and public auth deployment posture - public open-source notices surface required by HyperTwist's MPL distribution doctrine ## Why this app exists @@ -65,3 +66,7 @@ Before public launch, configure: Do not launch the public pricing/download pages without a valid open-source notices and corresponding-source destination. + +Recommended production posture is documented in: + +- `docs/ops/HYPERTWIST_WEBSITE_RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md` diff --git a/website/server/README.md b/website/server/README.md index 0cede81..54808e4 100644 --- a/website/server/README.md +++ b/website/server/README.md @@ -11,6 +11,7 @@ Responsibilities: - Paddle webhook endpoint with raw-body signature verification - verified billing-event application into first-party account/download entitlement state - public auth-health probe for the website with configured vs reachable core truth +- runtime configuration diagnostics for local vs mixed vs public auth posture ## Quick start @@ -61,3 +62,15 @@ The auth-health route now probes the configured SuperTokens core non-mutatively: - `SUPERTOKENS_HEALTH_TIMEOUT_MS` bounds the probe duration - `/api/auth/health` now distinguishes configured, reachable, and ready state - the protected dashboard can now show when browser fallback posture is active because the shared auth core is unreachable + +Recommended public `hypertwist.app` posture: + +- `API_DOMAIN=https://hypertwist.app` +- `WEBSITE_DOMAIN=https://hypertwist.app` +- `COOKIE_SECURE=true` +- real `PADDLE_WEBHOOK_SECRET` +- real plan-map configuration or equivalent verified-event plan resolution + +That recommended same-origin production posture is recorded in: + +- `docs/ops/HYPERTWIST_WEBSITE_RUNTIME_CONFIGURATION_GUIDE_2026-06-22.md` diff --git a/website/server/src/__tests__/runtime-config.test.ts b/website/server/src/__tests__/runtime-config.test.ts new file mode 100644 index 0000000..51748ad --- /dev/null +++ b/website/server/src/__tests__/runtime-config.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' + +import { getRuntimeConfigDiagnostics } from '../runtime-config' + +describe('getRuntimeConfigDiagnostics', () => { + it('marks loopback-only development posture as local and not public-ready', () => { + const diagnostics = getRuntimeConfigDiagnostics({ + apiDomain: 'http://localhost:3001', + websiteDomain: 'http://localhost:4273', + superTokensCoreUri: 'http://localhost:3567', + cookieSecure: false, + }) + + expect(diagnostics.mode).toBe('local') + expect(diagnostics.publicOriginReady).toBe(false) + expect(diagnostics.errors).toEqual([]) + expect(diagnostics.warnings).toContain('API_DOMAIN still targets a loopback/local-development origin.') + expect(diagnostics.warnings).toContain('WEBSITE_DOMAIN still targets a loopback/local-development origin.') + }) + + it('marks public https posture as ready when cookie hardening is enabled', () => { + const diagnostics = getRuntimeConfigDiagnostics({ + apiDomain: 'https://hypertwist.app', + websiteDomain: 'https://hypertwist.app', + superTokensCoreUri: 'https://auth-core.internal', + cookieSecure: true, + githubClientId: 'github-client-id', + githubClientSecret: 'github-client-secret', + }) + + expect(diagnostics.mode).toBe('public') + expect(diagnostics.publicOriginReady).toBe(true) + expect(diagnostics.errors).toEqual([]) + expect(diagnostics.warnings).toEqual([]) + }) + + it('flags invalid origins and partial oauth configuration', () => { + const diagnostics = getRuntimeConfigDiagnostics({ + apiDomain: 'https://hypertwist.app/auth', + websiteDomain: 'not-a-url', + superTokensCoreUri: 'http://localhost:3567', + cookieSecure: false, + googleClientId: 'only-client-id', + }) + + expect(diagnostics.publicOriginReady).toBe(false) + expect(diagnostics.errors).toContain('API_DOMAIN must not include a path; use API_BASE_PATH for route prefixing.') + expect(diagnostics.errors).toContain('WEBSITE_DOMAIN must be an absolute URL origin.') + expect(diagnostics.warnings).toContain('Google OAuth is only partially configured; set both client ID and client secret or neither.') + }) +}) diff --git a/website/server/src/index.ts b/website/server/src/index.ts index 5e04b80..0b84f3f 100644 --- a/website/server/src/index.ts +++ b/website/server/src/index.ts @@ -13,6 +13,7 @@ import GoogleProvider from 'supertokens-node/lib/build/recipe/thirdparty/provide import { probeSuperTokensCoreHealth } from './auth-health' import { createBillingStateStore, type BillingPlan, type BillingRole } from './billing-state' import { verifyPaddleWebhookSignature } from './paddle-webhook' +import { getRuntimeConfigDiagnostics } from './runtime-config' import { buildAllowedOriginMatcher, createDesktopLinkStore } from './security' const Github = GithubProvider as unknown as (options: { clientId: string; clientSecret: string; scope?: string[] }) => ReturnType @@ -41,6 +42,16 @@ 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 runtimeConfigDiagnostics = getRuntimeConfigDiagnostics({ + apiDomain: API_DOMAIN, + websiteDomain: WEBSITE_DOMAIN, + superTokensCoreUri: SUPERTOKENS_CORE_URI, + cookieSecure: COOKIE_SECURE, + githubClientId: GITHUB_CLIENT_ID, + githubClientSecret: GITHUB_CLIENT_SECRET, + googleClientId: GOOGLE_CLIENT_ID, + googleClientSecret: GOOGLE_CLIENT_SECRET, +}) function normalizeBillingPlan(value: string): BillingPlan { const plan = value.trim().toLowerCase() @@ -298,6 +309,15 @@ app.get('/api/auth/health', async (_req, res) => { productPlanMapConfigured: Object.keys(PADDLE_PRODUCT_PLAN_MAP).length > 0, webhookSecretConfigured: Boolean(PADDLE_WEBHOOK_SECRET), }, + runtime: { + mode: runtimeConfigDiagnostics.mode, + public_origin_ready: runtimeConfigDiagnostics.publicOriginReady, + cookie_secure: runtimeConfigDiagnostics.cookieSecure, + api_domain: runtimeConfigDiagnostics.apiDomain, + website_domain: runtimeConfigDiagnostics.websiteDomain, + warnings: runtimeConfigDiagnostics.warnings, + errors: runtimeConfigDiagnostics.errors, + }, }) }) @@ -406,4 +426,7 @@ 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 (runtimeConfigDiagnostics.errors.length > 0 || runtimeConfigDiagnostics.warnings.length > 0) { + console.warn('[hypertwist-auth-server] runtime configuration diagnostics', runtimeConfigDiagnostics) + } }) diff --git a/website/server/src/runtime-config.ts b/website/server/src/runtime-config.ts new file mode 100644 index 0000000..39f1415 --- /dev/null +++ b/website/server/src/runtime-config.ts @@ -0,0 +1,160 @@ +interface RuntimeConfigInput { + apiDomain: string + websiteDomain: string + superTokensCoreUri: string + cookieSecure: boolean + githubClientId?: string + githubClientSecret?: string + googleClientId?: string + googleClientSecret?: string +} + +export interface RuntimeConfigDiagnostics { + mode: 'local' | 'mixed' | 'public' + publicOriginReady: boolean + cookieSecure: boolean + warnings: string[] + errors: string[] + apiDomain: string + websiteDomain: string +} + +function normalizeTrimmed(value: string) { + return value.trim() +} + +function tryParseUrl(value: string) { + const trimmed = normalizeTrimmed(value) + if (!trimmed) { + return null + } + + try { + return new URL(trimmed) + } catch { + return null + } +} + +function hasOriginOnlyPath(url: URL) { + return url.pathname === '/' || url.pathname === '' +} + +function isLoopbackHostname(hostname: string) { + const normalized = hostname.trim().toLowerCase() + return normalized === 'localhost' + || normalized === '127.0.0.1' + || normalized === '::1' + || normalized === '[::1]' +} + +function isHttpsUrl(url: URL) { + return url.protocol === 'https:' +} + +function hasCompleteCredentialPair(valueA: string | undefined, valueB: string | undefined) { + return Boolean(String(valueA || '').trim() && String(valueB || '').trim()) +} + +export function getRuntimeConfigDiagnostics({ + apiDomain, + websiteDomain, + superTokensCoreUri, + cookieSecure, + githubClientId, + githubClientSecret, + googleClientId, + googleClientSecret, +}: RuntimeConfigInput): RuntimeConfigDiagnostics { + const warnings: string[] = [] + const errors: string[] = [] + + const parsedApiDomain = tryParseUrl(apiDomain) + const parsedWebsiteDomain = tryParseUrl(websiteDomain) + const parsedSuperTokensCoreUri = tryParseUrl(superTokensCoreUri) + + if (!parsedApiDomain) { + errors.push('API_DOMAIN must be an absolute URL origin.') + } else if (!hasOriginOnlyPath(parsedApiDomain)) { + errors.push('API_DOMAIN must not include a path; use API_BASE_PATH for route prefixing.') + } + + if (!parsedWebsiteDomain) { + errors.push('WEBSITE_DOMAIN must be an absolute URL origin.') + } else if (!hasOriginOnlyPath(parsedWebsiteDomain)) { + errors.push('WEBSITE_DOMAIN must not include a path; use WEBSITE_BASE_PATH for route prefixing.') + } + + if (!parsedSuperTokensCoreUri) { + errors.push('SUPERTOKENS_CORE_URI must be an absolute URL.') + } + + const apiIsLoopback = parsedApiDomain ? isLoopbackHostname(parsedApiDomain.hostname) : false + const websiteIsLoopback = parsedWebsiteDomain ? isLoopbackHostname(parsedWebsiteDomain.hostname) : false + const coreIsLoopback = parsedSuperTokensCoreUri ? isLoopbackHostname(parsedSuperTokensCoreUri.hostname) : false + const allLoopback = apiIsLoopback && websiteIsLoopback && coreIsLoopback + const noLoopback = !apiIsLoopback && !websiteIsLoopback && !coreIsLoopback + + if (apiIsLoopback) { + warnings.push('API_DOMAIN still targets a loopback/local-development origin.') + } + if (websiteIsLoopback) { + warnings.push('WEBSITE_DOMAIN still targets a loopback/local-development origin.') + } + if (coreIsLoopback) { + warnings.push('SUPERTOKENS_CORE_URI still targets a loopback/local-development host.') + } + + if (parsedApiDomain && !apiIsLoopback && !isHttpsUrl(parsedApiDomain)) { + warnings.push('API_DOMAIN uses HTTP on a non-loopback origin; use HTTPS before public launch.') + } + if (parsedWebsiteDomain && !websiteIsLoopback && !isHttpsUrl(parsedWebsiteDomain)) { + warnings.push('WEBSITE_DOMAIN uses HTTP on a non-loopback origin; use HTTPS before public launch.') + } + + if (!cookieSecure && !allLoopback) { + warnings.push('COOKIE_SECURE is disabled; production auth cookies will not be hardened.') + } + + if (cookieSecure && ( + (parsedApiDomain && !apiIsLoopback && !isHttpsUrl(parsedApiDomain)) + || (parsedWebsiteDomain && !websiteIsLoopback && !isHttpsUrl(parsedWebsiteDomain)) + )) { + warnings.push('COOKIE_SECURE is enabled while a non-loopback auth origin still uses HTTP; verify reverse-proxy TLS posture.') + } + + if (!hasCompleteCredentialPair(githubClientId, githubClientSecret) && (githubClientId || githubClientSecret)) { + warnings.push('GitHub OAuth is only partially configured; set both client ID and client secret or neither.') + } + + if (!hasCompleteCredentialPair(googleClientId, googleClientSecret) && (googleClientId || googleClientSecret)) { + warnings.push('Google OAuth is only partially configured; set both client ID and client secret or neither.') + } + + const mode: RuntimeConfigDiagnostics['mode'] = allLoopback + ? 'local' + : noLoopback + ? 'public' + : 'mixed' + + const publicOriginReady = Boolean( + parsedApiDomain + && parsedWebsiteDomain + && !apiIsLoopback + && !websiteIsLoopback + && isHttpsUrl(parsedApiDomain) + && isHttpsUrl(parsedWebsiteDomain) + && cookieSecure + && errors.length === 0 + ) + + return { + mode, + publicOriginReady, + cookieSecure, + warnings, + errors, + apiDomain: normalizeTrimmed(apiDomain), + websiteDomain: normalizeTrimmed(websiteDomain), + } +} diff --git a/website/src/__tests__/auth-env.test.ts b/website/src/__tests__/auth-env.test.ts index c73dac8..c083024 100644 --- a/website/src/__tests__/auth-env.test.ts +++ b/website/src/__tests__/auth-env.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getAuthRuntimeConfig } from '../auth/auth-env' +import { getAuthRuntimeConfig, validateAuthRuntimeConfig } from '../auth/auth-env' describe('getAuthRuntimeConfig', () => { it('normalizes loopback origins to the current browser origin', () => { @@ -14,4 +14,29 @@ describe('getAuthRuntimeConfig', () => { expect(config.superTokensWebsiteDomain).toBe('http://127.0.0.1:4273') expect(config.authApiBaseUrl).toBe('http://localhost:3001') }) + + it('warns when auth runtime still targets local development origins', () => { + const validation = validateAuthRuntimeConfig({ + VITE_SUPERTOKENS_API_DOMAIN: 'http://localhost:3001', + VITE_SUPERTOKENS_WEBSITE_DOMAIN: 'http://localhost:4273', + VITE_AUTH_API_BASE_URL: 'http://localhost:3001', + }) + + expect(validation.ready).toBe(true) + expect(validation.warnings).toContain('VITE_SUPERTOKENS_API_DOMAIN still targets a loopback/local-development origin.') + expect(validation.warnings).toContain('VITE_SUPERTOKENS_WEBSITE_DOMAIN still targets a loopback/local-development origin.') + expect(validation.warnings).toContain('VITE_AUTH_API_BASE_URL still targets a loopback/local-development origin.') + }) + + it('warns when public auth config still uses insecure http or split backends', () => { + const validation = validateAuthRuntimeConfig({ + VITE_SUPERTOKENS_API_DOMAIN: 'http://api.hypertwist.app', + VITE_SUPERTOKENS_WEBSITE_DOMAIN: 'http://hypertwist.app', + VITE_AUTH_API_BASE_URL: 'https://auth.hypertwist.app', + }) + + expect(validation.warnings).toContain('VITE_SUPERTOKENS_API_DOMAIN uses HTTP on a non-loopback origin; use HTTPS before public launch.') + expect(validation.warnings).toContain('VITE_SUPERTOKENS_WEBSITE_DOMAIN uses HTTP on a non-loopback origin; use HTTPS before public launch.') + expect(validation.warnings).toContain('VITE_AUTH_API_BASE_URL differs from VITE_SUPERTOKENS_API_DOMAIN; verify both point at the same auth backend.') + }) }) diff --git a/website/src/auth/auth-api.ts b/website/src/auth/auth-api.ts index e5a6a68..9109d13 100644 --- a/website/src/auth/auth-api.ts +++ b/website/src/auth/auth-api.ts @@ -74,6 +74,15 @@ export interface AuthHealthPayload { productPlanMapConfigured: boolean webhookSecretConfigured: boolean } + runtime: { + mode: 'local' | 'mixed' | 'public' + public_origin_ready: boolean + cookie_secure: boolean + api_domain: string + website_domain: string + warnings: string[] + errors: string[] + } } export interface ApiBootstrapUserPayload { diff --git a/website/src/auth/auth-env.ts b/website/src/auth/auth-env.ts index fd33fb4..949c099 100644 --- a/website/src/auth/auth-env.ts +++ b/website/src/auth/auth-env.ts @@ -2,6 +2,24 @@ function normalizeEnvValue(value: unknown) { return String(value || '').trim().replace(/\/$/, '') } +function tryParseUrl(value: string) { + const trimmed = normalizeEnvValue(value) + if (!trimmed) return null + try { + return new URL(trimmed) + } catch { + return null + } +} + +function isLoopbackHostname(hostname: string) { + const normalized = hostname.trim().toLowerCase() + return normalized === 'localhost' + || normalized === '127.0.0.1' + || normalized === '::1' + || normalized === '[::1]' +} + function normalizeLoopbackOrigin(configuredOrigin: string, locationOrigin: string) { if (!configuredOrigin || !locationOrigin) return configuredOrigin @@ -84,6 +102,34 @@ export function validateAuthRuntimeConfig( warnings.push('VITE_AUTH_API_BASE_URL not configured; same-origin auth fallback remains active.') } + const apiUrl = tryParseUrl(config.superTokensApiDomain) + const websiteUrl = tryParseUrl(config.superTokensWebsiteDomain) + const authApiUrl = tryParseUrl(config.authApiBaseUrl) + + if (apiUrl && isLoopbackHostname(apiUrl.hostname)) { + warnings.push('VITE_SUPERTOKENS_API_DOMAIN still targets a loopback/local-development origin.') + } + if (websiteUrl && isLoopbackHostname(websiteUrl.hostname)) { + warnings.push('VITE_SUPERTOKENS_WEBSITE_DOMAIN still targets a loopback/local-development origin.') + } + if (authApiUrl && isLoopbackHostname(authApiUrl.hostname)) { + warnings.push('VITE_AUTH_API_BASE_URL still targets a loopback/local-development origin.') + } + + if (apiUrl && !isLoopbackHostname(apiUrl.hostname) && apiUrl.protocol !== 'https:') { + warnings.push('VITE_SUPERTOKENS_API_DOMAIN uses HTTP on a non-loopback origin; use HTTPS before public launch.') + } + if (websiteUrl && !isLoopbackHostname(websiteUrl.hostname) && websiteUrl.protocol !== 'https:') { + warnings.push('VITE_SUPERTOKENS_WEBSITE_DOMAIN uses HTTP on a non-loopback origin; use HTTPS before public launch.') + } + if (authApiUrl && !isLoopbackHostname(authApiUrl.hostname) && authApiUrl.protocol !== 'https:') { + warnings.push('VITE_AUTH_API_BASE_URL uses HTTP on a non-loopback origin; use HTTPS before public launch.') + } + + if (config.authApiBaseUrl && config.superTokensApiDomain && config.authApiBaseUrl !== config.superTokensApiDomain) { + warnings.push('VITE_AUTH_API_BASE_URL differs from VITE_SUPERTOKENS_API_DOMAIN; verify both point at the same auth backend.') + } + return { ready: missing.length === 0, missing, diff --git a/website/src/pages/app-pages.tsx b/website/src/pages/app-pages.tsx index b7a688c..249c09a 100644 --- a/website/src/pages/app-pages.tsx +++ b/website/src/pages/app-pages.tsx @@ -24,7 +24,7 @@ function Panel({ } export function DashboardOverviewPage() { - const { user } = usePlatformAuth() + const { user, superTokensConfigured } = usePlatformAuth() const healthQuery = useQuery({ queryKey: ['auth-health'], queryFn: getAuthHealth, @@ -49,6 +49,7 @@ export function DashboardOverviewPage() { if (!launchReadiness.mplSourceConfigured) issues.push('MPL corresponding-source URL missing') if (!launchReadiness.openSourceRepoConfigured) issues.push('Open-source repository/notices URL missing') if (healthQuery.data && !healthQuery.data.billing.webhookSecretConfigured) issues.push('Paddle webhook secret missing') + if (healthQuery.data && !healthQuery.data.runtime.public_origin_ready) issues.push('Public auth runtime still uses local or mixed deployment posture') return issues }, [healthQuery.data]) @@ -60,6 +61,11 @@ export function DashboardOverviewPage() {

Plan: {user?.plan}

Role: {user?.role || 'operator'}

Desktop downloads: {user?.canDownload ? 'enabled' : 'not yet entitled'}

+ {!superTokensConfigured || user?.billing?.source === 'local-fallback' ? ( +

+ This session is currently using local fallback posture, not fully shared production auth. +

+ ) : null} @@ -75,6 +81,9 @@ export function DashboardOverviewPage() {
  • Fallback active: {healthQuery.data.fallback.active ? 'yes' : 'no'}
  • Core API version: {healthQuery.data.supertokens.apiVersion || 'unavailable'}
  • OAuth routes: GitHub {healthQuery.data.supertokens.oauth?.github ? 'on' : 'off'}, Google {healthQuery.data.supertokens.oauth?.google ? 'on' : 'off'}
  • +
  • Runtime mode: {healthQuery.data.runtime.mode}
  • +
  • Cookie secure: {healthQuery.data.runtime.cookie_secure ? 'yes' : 'no'}
  • +
  • Public auth origin ready: {healthQuery.data.runtime.public_origin_ready ? 'yes' : 'no'}
  • ) : null} {healthQuery.data?.fallback.active ? ( @@ -82,6 +91,25 @@ export function DashboardOverviewPage() { Shared auth core is not fully ready right now. Dashboard fallback posture remains available{healthQuery.data.fallback.reason ? ` (${healthQuery.data.fallback.reason})` : ''}.

    ) : null} + {healthQuery.data && (healthQuery.data.runtime.errors.length > 0 || healthQuery.data.runtime.warnings.length > 0) ? ( +
    +

    Runtime deployment diagnostics

    + {healthQuery.data.runtime.errors.length > 0 ? ( +
      + {healthQuery.data.runtime.errors.map((item) => ( +
    • Error: {item}
    • + ))} +
    + ) : null} + {healthQuery.data.runtime.warnings.length > 0 ? ( +
      + {healthQuery.data.runtime.warnings.map((item) => ( +
    • Warning: {item}
    • + ))} +
    + ) : null} +
    + ) : null}
    @@ -127,6 +155,7 @@ export function DashboardOverviewPage() {
  • Paddle webhook secret: {!healthQuery.data ? 'checking' : (healthQuery.data.billing.webhookSecretConfigured ? 'configured' : 'missing')}
  • Billing product map: {!healthQuery.data ? 'checking' : (healthQuery.data.billing.productPlanMapConfigured ? 'configured' : 'missing')}
  • Billing price map: {!healthQuery.data ? 'checking' : (healthQuery.data.billing.pricePlanMapConfigured ? 'configured' : 'missing')}
  • +
  • Public auth runtime posture: {!healthQuery.data ? 'checking' : (healthQuery.data.runtime.public_origin_ready ? 'production-ready' : 'local-or-mixed')}
  • {launchReadinessIssues.length > 0 ? (

    diff --git a/website/src/pages/auth-pages.tsx b/website/src/pages/auth-pages.tsx index 9a19b8e..ba360a3 100644 --- a/website/src/pages/auth-pages.tsx +++ b/website/src/pages/auth-pages.tsx @@ -1,7 +1,14 @@ import { useEffect, useState } from 'react' import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { usePlatformAuth } from '../auth/platform-auth' -import { isGitHubOAuthEnabled, isGoogleOAuthEnabled, isOrcidOAuthEnabled } from '../auth/supertokens-client' +import { + getSuperTokensAuthRuntimeValidation, + isGitHubOAuthEnabled, + isGoogleOAuthEnabled, + isOrcidOAuthEnabled, +} from '../auth/supertokens-client' + +const authRuntimeValidation = getSuperTokensAuthRuntimeValidation() function AuthShell({ title, @@ -67,6 +74,28 @@ export function LoginPage() { subtitle="Use the same browser auth posture as FamiliarOS and ScriptoriumAI, then hand off to the desktop runtime when needed." footer={

    Need access? Create an account.

    } > + {!authRuntimeValidation.ready || authRuntimeValidation.warnings.length > 0 ? ( +
    +

    + Shared browser auth is not fully in production posture yet. + {!authRuntimeValidation.ready ? ' Local fallback mode may activate until the required auth env vars are configured.' : ''} +

    + {authRuntimeValidation.missing.length > 0 ? ( +
      + {authRuntimeValidation.missing.map((item) => ( +
    • Missing auth env: {item}
    • + ))} +
    + ) : null} + {authRuntimeValidation.warnings.length > 0 ? ( +
      + {authRuntimeValidation.warnings.map((item) => ( +
    • {item}
    • + ))} +
    + ) : null} +
    + ) : null}
    setEmail(event.target.value)} /> @@ -133,6 +162,28 @@ export function RegisterPage() { subtitle="This unlocks the browser dashboard, release posture, and desktop-link pairing for the simulator lane." footer={

    Already have access? Log in.

    } > + {!authRuntimeValidation.ready || authRuntimeValidation.warnings.length > 0 ? ( +
    +

    + Shared browser auth is not fully in production posture yet. + {!authRuntimeValidation.ready ? ' Local fallback mode may activate until the required auth env vars are configured.' : ''} +

    + {authRuntimeValidation.missing.length > 0 ? ( +
      + {authRuntimeValidation.missing.map((item) => ( +
    • Missing auth env: {item}
    • + ))} +
    + ) : null} + {authRuntimeValidation.warnings.length > 0 ? ( +
      + {authRuntimeValidation.warnings.map((item) => ( +
    • {item}
    • + ))} +
    + ) : null} +
    + ) : null} setName(event.target.value)} />