Add website auth surfaces and package validation proof
This commit is contained in:
parent
a2a005979f
commit
5bb8b31ef9
58 changed files with 12682 additions and 6 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -7,6 +7,10 @@ __pycache__/
|
|||
TODO.json
|
||||
Content/Browser/dist/
|
||||
Content/Browser/node_modules/
|
||||
website/dist/
|
||||
website/node_modules/
|
||||
website/server/node_modules/
|
||||
website/server/data/
|
||||
|
||||
# Sensitive or reference-only material.
|
||||
docs/refs/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
{
|
||||
"reportVersion": "ht-higher-dimensional-package-validation/v1",
|
||||
"generatedAtUtc": "2026-06-22T01:43:08.7625247Z",
|
||||
"projectRoot": "C:/HyperTwist_worktrees/phase10validate",
|
||||
"archiveDirectory": "C:/HyperTwist_worktrees/phase10validate_packaged_phase6c_higherdim",
|
||||
"configuration": "Development",
|
||||
"cookMaps": [
|
||||
"/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining",
|
||||
"/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining"
|
||||
],
|
||||
"smokeMaps": [
|
||||
"/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining",
|
||||
"/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining"
|
||||
],
|
||||
"additionalCookerOptions": [
|
||||
"-DisablePlugins=MovieRenderPipeline",
|
||||
"-SkipCookingEditorContent"
|
||||
],
|
||||
"cleanArchive": true,
|
||||
"skipBuild": true,
|
||||
"skipLaunch": false,
|
||||
"authoringManifest": {
|
||||
"path": "C:\\HyperTwist_worktrees\\phase10validate\\docs\\generated\\higher_dimensional_training_maps\\phase6c_dedicated_family_map_manifest.json",
|
||||
"manifestId": "phase6c/dedicated-family-training-map-authoring",
|
||||
"manifestVersion": "2026.06.18",
|
||||
"authoredThroughGameModeClassPath": "/Script/UnrealHyperTwist.HyperTwistCoachDashboardGameMode",
|
||||
"validatedEntries": [
|
||||
{
|
||||
"mapKind": "magic120cell",
|
||||
"familyKey": "magic120cell",
|
||||
"mapAssetPath": "/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining",
|
||||
"mapFileRelativePath": "UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining.umap",
|
||||
"mapHashMd5": "9d9b9301f14cdad8c263d2c57b5f2c7f",
|
||||
"activationProfileId": "magic120cell-cleanroom-runtime-activation",
|
||||
"runtimeModeId": "magic120cell-full-color-runtime-v1",
|
||||
"projectionProfileId": "magic120cell-4d-projection-distance-v1",
|
||||
"primaryPersistenceBoundaryId": "magic120cell-persistence-boundary"
|
||||
},
|
||||
{
|
||||
"mapKind": "magiccube5d",
|
||||
"familyKey": "magiccube5d",
|
||||
"mapAssetPath": "/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining",
|
||||
"mapFileRelativePath": "UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining.umap",
|
||||
"mapHashMd5": "15d2f7acfdce43402ad2fd291fc5c781",
|
||||
"activationProfileId": "magiccube5d-cleanroom-runtime-activation",
|
||||
"runtimeModeId": "magiccube5d-order3-runtime-v1",
|
||||
"projectionProfileId": "magiccube5d-5d-projection-distance-v1",
|
||||
"primaryPersistenceBoundaryId": "magiccube5d-persistence-boundary"
|
||||
}
|
||||
]
|
||||
},
|
||||
"result": "passed",
|
||||
"packagedExecutablePath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_phase6c_higherdim\\Windows\\UnrealHyperTwist.exe",
|
||||
"smokeReports": [
|
||||
{
|
||||
"reportVersion": "ht-higher-dimensional-package-smoke/v1",
|
||||
"generatedAtUtc": "2026-06-22T01:45:12.6335785Z",
|
||||
"packageRoot": "C:\\HyperTwist_worktrees\\phase10validate_packaged_phase6c_higherdim",
|
||||
"executablePath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_phase6c_higherdim\\Windows\\UnrealHyperTwist.exe",
|
||||
"mapUrl": "/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining",
|
||||
"smokeSeconds": 10,
|
||||
"resolution": {
|
||||
"width": 1600,
|
||||
"height": 900
|
||||
},
|
||||
"keepRunning": false,
|
||||
"result": "passed",
|
||||
"processId": 29436,
|
||||
"processStopped": true,
|
||||
"exitCode": null,
|
||||
"error": null
|
||||
},
|
||||
{
|
||||
"reportVersion": "ht-higher-dimensional-package-smoke/v1",
|
||||
"generatedAtUtc": "2026-06-22T01:45:23.3808621Z",
|
||||
"packageRoot": "C:\\HyperTwist_worktrees\\phase10validate_packaged_phase6c_higherdim",
|
||||
"executablePath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_phase6c_higherdim\\Windows\\UnrealHyperTwist.exe",
|
||||
"mapUrl": "/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining",
|
||||
"smokeSeconds": 10,
|
||||
"resolution": {
|
||||
"width": 1600,
|
||||
"height": 900
|
||||
},
|
||||
"keepRunning": false,
|
||||
"result": "passed",
|
||||
"processId": 35616,
|
||||
"processStopped": true,
|
||||
"exitCode": null,
|
||||
"error": null
|
||||
}
|
||||
],
|
||||
"error": null
|
||||
}
|
||||
|
|
@ -369,6 +369,7 @@ Closure read:
|
|||
- `2026-06-18` Windows checkpoint: after that projection-deferral hardening rebuild with `Result: Succeeded` / UnrealBuildTool `Total execution time: 400.77 seconds`, targeted `UnrealEditor-Cmd` automation exported `C:\HyperTwist_worktrees\phase10validate\UnrealHyperTwist\Saved\AutomationReports\Phase6C-DedicatedFamily-Optimized2\index.json` with all `11` `HyperTwist.FirstParty.HigherDimensional.Phase6C` tests passing, `totalDuration` `41.88494873046875`, and `LaunchExecution` duration `41.53509521484375`; the controller quiet interval fell again to `41.52 seconds` while retaining `**** TEST COMPLETE. EXIT CODE: 0 ****`
|
||||
- `2026-06-18` recovery proof: current code now deterministically reauthors the dedicated-family `Magic120Cell` / `MagicCube5D` maps through `scripts/hypertwist_author_higher_dimensional_training_maps.py`, stamps `AHyperTwistCoachDashboardGameMode` plus manifest-backed `PlayerStart` ownership tags for activation, host, launch, view-context, session, interactive-scene, scene-context, puzzle, runtime-mode, projection, persistence, and dedicated-family ownership, and records the refreshed owned-map hashes in `docs/generated/higher_dimensional_training_maps/phase6c_dedicated_family_map_manifest.json`; the optional `AHyperTwistHigherDimensionalTrainingShellActor` remains available for manual/editor authoring, but the headless `UnrealEditor-Cmd` lane now intentionally skips spawning it because that path still crashes the commandlet with `EXCEPTION_INT_DIVIDE_BY_ZERO`
|
||||
- `2026-06-18` Windows checkpoint: after patching the missing dedicated-anchor puzzle tag, the same isolated worktree `C:\HyperTwist_worktrees\phase10validate` reran `scripts\Invoke-HyperTwistHigherDimensionalMapAuthoring.ps1` to `Higher-dimensional authored maps verified.`, then targeted `UnrealEditor-Cmd` automation exported `C:\HyperTwist_worktrees\phase10validate\UnrealHyperTwist\Saved\AutomationReports\Phase6C-DedicatedMapOwnership\index.json`; the pulled local proof at `docs/generated/higher_dimensional_training_maps/phase6c_dedicated_map_ownership_automation_report.json` now shows all `13` `HyperTwist.FirstParty.HigherDimensional.Phase6C` tests passing, including `DedicatedMapAuthoringManifest` and `DedicatedMapOwnedShell`, with `**** TEST COMPLETE. EXIT CODE: 0 ****`
|
||||
- `2026-06-22` Windows checkpoint: the same primary reverse-SSH `localhost:22022` lane then re-used isolated worktree `C:\HyperTwist_worktrees\phase10validate` for the maintained higher-dimensional package helper, ran `scripts\Invoke-HyperTwistHigherDimensionalPackage.ps1` in `-SkipBuild` mode against the existing target receipt, validated the manifest-backed `Magic120Cell` / `MagicCube5D` dedicated-family map hashes before cook, completed `BuildCookRun` with `ExitCode=0` and `BuildCookRun time: 118.53 s`, wrote archive output to `C:\HyperTwist_worktrees\phase10validate_packaged_phase6c_higherdim`, smoke-booted both `/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining` and `/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining`, and pulled aggregate proof into `docs/generated/higher_dimensional_training_maps/phase6c_dedicated_family_package_validation_report.json`
|
||||
- [x] Replace the current placeholder interactive-scene envelopes with full owned `120-cell` and `5D` simulation-state, projection, and persistence ownership in Unreal above the landed higher-dimensional scene-context catalog
|
||||
- [x] Replace the current shared-map execution bridge with full owned dedicated-family interactive launch, map, and persistence surfaces for `120-cell` and `5D`
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
# HyperTwist Public Website, Auth, Billing, and Distribution Packet
|
||||
|
||||
Created on `2026-06-22`.
|
||||
|
||||
## Purpose
|
||||
|
||||
This packet records the first-party public `hypertwist.app` lane that now sits
|
||||
beside the existing embedded Unreal browser runtime.
|
||||
|
||||
It closes one product-surface gap:
|
||||
|
||||
- HyperTwist now has a real public website and browser operator shell
|
||||
|
||||
It does **not** reopen any previously frozen topology branch:
|
||||
|
||||
- it does not claim the optional full-browser simulator is live
|
||||
- it does not reopen the native `MagicTile` renderer-port branch
|
||||
- it does not replace the embedded Unreal browser/CEF shipping posture
|
||||
|
||||
## Why this lane had to exist separately
|
||||
|
||||
HyperTwist already had a browser runtime under:
|
||||
|
||||
- `Content/Browser/`
|
||||
|
||||
That runtime is the embedded Unreal browser/CEF shell, not the public website,
|
||||
pricing page, download page, auth shell, or account dashboard.
|
||||
|
||||
The public product/distribution surface now lives under:
|
||||
|
||||
- `website/`
|
||||
|
||||
## Cross-repo reference posture
|
||||
|
||||
This packet intentionally reused website structure and auth posture from:
|
||||
|
||||
- FamiliarOS website structure, public-page layout discipline, and centered loading spinner
|
||||
- ScriptoriumAI browser dashboard and protected-app-shell posture
|
||||
|
||||
But the resulting code is now:
|
||||
|
||||
- first-party HyperTwist-owned
|
||||
- rebranded to HyperTwist content and product truth
|
||||
- constrained by HyperTwist roadmap and feature-registry authority
|
||||
|
||||
## Landed first-party surfaces
|
||||
|
||||
### Frontend app
|
||||
|
||||
- `website/src/App.tsx`
|
||||
- `website/src/router/AppRouteTree.tsx`
|
||||
- `website/src/router/PublicRoutes.tsx`
|
||||
- `website/src/components/layout/MarketingShell.tsx`
|
||||
- `website/src/components/layout/AppShell.tsx`
|
||||
- `website/src/components/ui/Skeletons.tsx`
|
||||
- `website/src/pages/public-pages.tsx`
|
||||
- `website/src/pages/auth-pages.tsx`
|
||||
- `website/src/pages/app-pages.tsx`
|
||||
|
||||
Current public/product routes now include:
|
||||
|
||||
- `/`
|
||||
- `/about`
|
||||
- `/resources`
|
||||
- `/docs`
|
||||
- `/support`
|
||||
- `/changelog`
|
||||
- `/pricing`
|
||||
- `/download`
|
||||
- `/open-source-notices`
|
||||
- `/privacy`
|
||||
- `/terms`
|
||||
- `/shipping-payment`
|
||||
- `/login`
|
||||
- `/register`
|
||||
- protected `/app` routes for browser-based operator access
|
||||
|
||||
### Auth posture
|
||||
|
||||
Shared auth posture now exists through:
|
||||
|
||||
- `website/src/auth/auth-env.ts`
|
||||
- `website/src/auth/auth-api.ts`
|
||||
- `website/src/auth/supertokens-client.ts`
|
||||
- `website/src/auth/platform-auth.tsx`
|
||||
|
||||
Current behavior:
|
||||
|
||||
- shared SuperTokens auth frontend posture aligned with FamiliarOS and ScriptoriumAI
|
||||
- email/password login and registration
|
||||
- optional GitHub and Google OAuth flags
|
||||
- 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
|
||||
|
||||
### Browser dashboard posture
|
||||
|
||||
Protected browser dashboard routes now expose:
|
||||
|
||||
- account state
|
||||
- auth/server health
|
||||
- desktop-link token generation
|
||||
- download/release posture
|
||||
- billing and entitlement state
|
||||
- browser-access boundary explanation
|
||||
- notices review
|
||||
|
||||
That auth/server health surface now also reports:
|
||||
|
||||
- whether the shared SuperTokens core is merely configured or actually reachable
|
||||
- the reported core API version when reachable
|
||||
- whether fallback posture is currently active and why
|
||||
|
||||
This is browser-based user access for the operator/account surface.
|
||||
|
||||
It is **not** a claim that the simulator itself is now browser-owned.
|
||||
|
||||
### Desktop distribution and pairing posture
|
||||
|
||||
The website now includes:
|
||||
|
||||
- public download center
|
||||
- plan-gated release posture
|
||||
- browser-to-desktop token handoff
|
||||
- protected-dashboard download gating above the public target-description page
|
||||
|
||||
Current pairing endpoint family:
|
||||
|
||||
- `POST /api/auth/desktop-link`
|
||||
- `GET /api/auth/desktop-link/verify`
|
||||
|
||||
Current hardening on that lane now also includes:
|
||||
|
||||
- exact website-origin matching instead of prefix-only acceptance
|
||||
- bounded per-user desktop-link issuance
|
||||
- one-time verification-token consumption after the first successful desktop-side use
|
||||
- billing-resolved plan/role plus download-entitlement posture carried into the desktop-link token rather than stale session defaults
|
||||
- public `/download` messaging that routes real download access through the protected dashboard instead of exposing raw release URLs on the marketing page
|
||||
|
||||
### Billing posture
|
||||
|
||||
Pricing now exists as a public first-party page and is wired for:
|
||||
|
||||
- env-configured plan labels
|
||||
- env-configured Paddle checkout URLs
|
||||
- shared webhook endpoint family
|
||||
|
||||
Current billing endpoint:
|
||||
|
||||
- `POST /api/billing/paddle/webhook`
|
||||
|
||||
Current billing hardening now includes:
|
||||
|
||||
- raw-body signature verification against `PADDLE_WEBHOOK_SECRET`
|
||||
- `Paddle-Signature` timestamp tolerance enforcement
|
||||
- bounded first-party billing-state persistence
|
||||
- verified-event application into account/download entitlement state
|
||||
- focused backend tests for valid, malformed, expired, and mismatched signatures
|
||||
|
||||
## Public legal and MPL distribution posture
|
||||
|
||||
This packet explicitly satisfies the earlier doctrine requirement that public
|
||||
distribution surfaces must expose legal/notices guidance when shipped builds
|
||||
contain `MPL`-covered material.
|
||||
|
||||
The website now has:
|
||||
|
||||
- a public `Open Source Notices` route
|
||||
- footer linkage
|
||||
- pricing/download/legal linkage
|
||||
- corresponding-source configuration slots
|
||||
|
||||
This packet does **not** pretend deployment is finished.
|
||||
|
||||
Before public launch, operators still must configure:
|
||||
|
||||
- the real public corresponding-source URL
|
||||
- real Paddle checkout URLs
|
||||
- real production download URLs
|
||||
- live notification-destination secret management
|
||||
- any broader operator/admin billing workflow beyond the current bounded entitlement application
|
||||
|
||||
## Validation completed
|
||||
|
||||
Frontend validation:
|
||||
|
||||
- `npm run type-check`
|
||||
- `npm test`
|
||||
- `npm run build`
|
||||
|
||||
Backend validation:
|
||||
|
||||
- `npm run type-check`
|
||||
- `npm test`
|
||||
|
||||
Validation result on `2026-06-22`:
|
||||
|
||||
- frontend type-check passed
|
||||
- frontend tests passed
|
||||
- frontend production build passed
|
||||
- backend type-check passed
|
||||
- backend focused security tests passed
|
||||
- `website/server` billing/entitlement tests passed
|
||||
- the new bounded auth-health probe tests passed, and the protected dashboard type/build surface remained green after adopting the richer health contract
|
||||
|
||||
## Explicit non-claims
|
||||
|
||||
This packet does not claim any of the following:
|
||||
|
||||
- public browser simulator parity with the native Unreal runtime
|
||||
- live production pricing values
|
||||
- live production download URLs
|
||||
- completed public corresponding-source hosting
|
||||
|
||||
Those are deployment/runtime configuration tasks, not missing ownership of the
|
||||
public website lane itself.
|
||||
|
|
@ -391,6 +391,49 @@ Live follow-up on `2026-06-18` established these additional facts:
|
|||
`Ignoring very large delta of 499.13 seconds`, so the quiet interval is now
|
||||
known-good on this lane rather than a default corruption indicator
|
||||
|
||||
## Addendum - 2026-06-22 (higher-dimensional structured package-report proof)
|
||||
|
||||
Live follow-up on `2026-06-22` established these additional facts:
|
||||
|
||||
- the primary `localhost:22022` lane remained healthy for the current
|
||||
higher-dimensional package-helper proof
|
||||
- the validated route again used the isolated Windows tree
|
||||
`C:\HyperTwist_worktrees\phase10validate`
|
||||
- the maintained helper pair
|
||||
`scripts\Invoke-HyperTwistHigherDimensionalPackage.ps1` and
|
||||
`scripts\Launch-HyperTwistHigherDimensionalPackage.ps1` was narrow-synced
|
||||
into that isolated worktree without forcing a broad reverse-sync
|
||||
- the packaged-game target receipt was already present there, so the helper was
|
||||
validated in `-SkipBuild` mode rather than spending time on an unrelated full
|
||||
game rebuild for this script-only slice
|
||||
- before `BuildCookRun`, the helper verified that the
|
||||
`Magic120Cell` / `MagicCube5D` dedicated-family `.umap` files still matched
|
||||
the owned hashes recorded in
|
||||
`docs\generated\higher_dimensional_training_maps\phase6c_dedicated_family_map_manifest.json`
|
||||
- `BuildCookRun` then succeeded from that same worktree with `ExitCode=0` and
|
||||
`BuildCookRun time: 118.53 s`
|
||||
- archive output was written to
|
||||
`C:\HyperTwist_worktrees\phase10validate_packaged_phase6c_higherdim`
|
||||
- packaged smoke launch succeeded for
|
||||
`/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining`
|
||||
- packaged smoke launch also succeeded for
|
||||
`/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining`
|
||||
- the maintained helper wrote aggregate report JSON to
|
||||
`C:\HyperTwist_worktrees\phase10validate_packaged_phase6c_higherdim\validation\higher-dimensional-package-validation-report.json`
|
||||
- the same aggregate proof was then pulled back into the tracked repo at
|
||||
`docs/generated/higher_dimensional_training_maps/phase6c_dedicated_family_package_validation_report.json`
|
||||
|
||||
Operational rules reinforced by this proof:
|
||||
|
||||
- for a script-only packaging helper slice on this lane, prefer a narrow file
|
||||
sync plus `-SkipBuild` when the packaged-game target receipt already exists
|
||||
- when a package helper validates manifest-backed map freshness before cook,
|
||||
treat that manifest gate as part of the proof rather than as incidental log
|
||||
noise
|
||||
- when a remote packaged-validation archive becomes relied-on source-backed
|
||||
evidence, pull the aggregate report back into the tracked repo before calling
|
||||
the lane closed
|
||||
|
||||
## Addendum - 2026-06-13 (replay, leaderboard, and structured package-report proof)
|
||||
|
||||
Live follow-up on `2026-06-13` established these additional facts:
|
||||
|
|
|
|||
|
|
@ -193,6 +193,24 @@ can legitimately trigger controller output such as
|
|||
healthy, so that quiet interval is now an expected operational characteristic
|
||||
of this slice rather than a default corruption signal.
|
||||
|
||||
Additional live proof on `2026-06-22` then closed the current maintained
|
||||
higher-dimensional package-helper lane on that same primary reverse-SSH path:
|
||||
the isolated Windows worktree `C:\HyperTwist_worktrees\phase10validate`
|
||||
already had the packaged-game target receipt, so the new helper
|
||||
`scripts\Invoke-HyperTwistHigherDimensionalPackage.ps1` was validated in
|
||||
`-SkipBuild` mode against the existing receipt, insisted on manifest-backed
|
||||
`Magic120Cell` / `MagicCube5D` dedicated-family map hash parity before cook,
|
||||
completed `BuildCookRun` with `ExitCode=0` and `BuildCookRun time: 118.53 s`,
|
||||
wrote archive output to
|
||||
`C:\HyperTwist_worktrees\phase10validate_packaged_phase6c_higherdim`,
|
||||
smoke-launched both higher-dimensional dedicated-family training maps through
|
||||
`scripts\Launch-HyperTwistHigherDimensionalPackage.ps1`, and pulled aggregate
|
||||
proof into
|
||||
`docs/generated/higher_dimensional_training_maps/phase6c_dedicated_family_package_validation_report.json`.
|
||||
This extends the doctrine from higher-dimensional activation/ownership proof
|
||||
into current structured package/archive/launch proof on the same primary
|
||||
reverse-SSH lane.
|
||||
|
||||
Further `2026-06-13` continuation proof on that same primary lane recovered
|
||||
the classic-cube replay/leaderboard/package slice after a corrupted reverse
|
||||
sync incident: a prior aborted broad tar sync left remote
|
||||
|
|
|
|||
|
|
@ -259,7 +259,17 @@ repo.
|
|||
| Local social challenge bundle | Implemented now | landed `kash/cubedesk` `Bound 5` | Live bounded slice. |
|
||||
| Broader admin/report lane | Deep-source grounded retained | repo-local justification required | `Bound 6` is deferred and not currently justified for implementation. |
|
||||
|
||||
### 6. Hyper puzzle topology and non-Euclidean runtime
|
||||
### 6. Public website, distribution, and account surfaces
|
||||
|
||||
| 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, and 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. |
|
||||
| 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. |
|
||||
|
||||
### 7. Hyper puzzle topology and non-Euclidean runtime
|
||||
|
||||
| Feature | Status | Primary authority | Notes |
|
||||
|---|---|---|---|
|
||||
|
|
@ -279,10 +289,10 @@ repo.
|
|||
| Higher-dimensional initial shared-map launch and view-context execution bridge | Implemented now | landed `Phase 6C` continuation packet | First-party current code first bundled higher-dimensional launch surfaces and view-context surfaces for `Magic120Cell` and `MagicCube5D`, resolved those surfaces through the training runtime library plus the live training panel/session-actor caches, and let the training subsystem start the shared-map seed run, apply default selector posture, persist the generated-mode launch request, and start the owned generated run without widening into external-process ownership. After recovering an interrupted follow-up, the primary reverse-SSH `localhost:22022` Windows lane rebuilt that cleaned slice on `2026-06-18` with `Result: Succeeded`, UnrealBuildTool `Total execution time: 124.81 seconds`, and passed all `8` `HyperTwist.FirstParty.HigherDimensional.Phase6C` tests, including `LaunchCatalog`, `ViewContextCatalog`, `LaunchExecution`, and `RunStateActivationResolution`; that initial bridge is now superseded by the later dedicated-family map ownership continuation. |
|
||||
| Higher-dimensional explicit dedicated-host seam | Implemented now | landed `Phase 6C` host-surface continuation packet | First-party current code then recorded the preferred dedicated-family `Magic120Cell` / `MagicCube5D` host-map targets in an explicit higher-dimensional host-surface catalog while the slice still kept the shared training map as the interim effective runtime fallback, mirrored that resolved host seam through the training runtime library plus the panel/session caches, and threaded the host-surface id into the launch/view-context surfaces and generated-mode lineage. The primary reverse-SSH `localhost:22022` Windows lane rebuilt this widened slice on `2026-06-18` with `Result: Succeeded`, UnrealBuildTool `Total execution time: 2858.02 seconds`, and passed all `9` `HyperTwist.FirstParty.HigherDimensional.Phase6C` tests, including the new `HostCatalog`; that interim fallback is now superseded by the later dedicated-family map ownership continuation. |
|
||||
| Higher-dimensional executed runtime session surface | Implemented now | landed `Phase 6C` session-surface continuation packet | First-party current code now merges the higher-dimensional launch, host, and view-context seams into an explicit runtime-session catalog for `Magic120Cell` and `MagicCube5D`, mirrors that session surface through the training runtime library plus the panel/session caches, upgrades generated-mode deck lineage to record the resolved session surface, and applies the merged launch-plus-view selector posture at start time instead of only the narrower launch defaults. The primary reverse-SSH `localhost:22022` Windows lane rebuilt this widened slice on `2026-06-18` with `Result: Succeeded`, UnrealBuildTool `Total execution time: 3541.98 seconds`, and passed all `10` `HyperTwist.FirstParty.HigherDimensional.Phase6C` tests, including the new `SessionCatalog`; the longer `LaunchExecution` quiet interval of `573.84 seconds` remained known-good. |
|
||||
| Higher-dimensional dedicated-family interactive scene ownership | Implemented now | landed `Phase 6C` interactive-scene and dedicated-host continuation packet | First-party current code now promotes the higher-dimensional runtime-session seams into explicit `Magic120Cell` and `MagicCube5D` interactive-scene surfaces with family-owned runtime-state, projection, and persistence envelopes, replaces the shared-map bridge with dedicated-family training-map assets plus dedicated host/launch/view-context/session surfaces, exposes explicit scene-envelope decoders through the runtime library, batches default selector application so generated-mode launch execution no longer rebuilds repository views once per selector, and then defers repository-view projection across the internal generated-mode handoff itself so only the final launch state forces a view refresh. The primary reverse-SSH `localhost:22022` Windows lane rebuilt the dedicated-family ownership slice on `2026-06-18` with `Result: Succeeded`, UnrealBuildTool `Total execution time: 1648.33 seconds`; the later projection-deferral hardening rebuild completed in `400.77 seconds`, and the exported automation report passed all `11` `HyperTwist.FirstParty.HigherDimensional.Phase6C` tests with `totalDuration` `41.88494873046875`, `LaunchExecution` duration `41.53509521484375`, and the controller quiet interval reduced to `41.52 seconds`. The current recovery-proof continuation now also deterministically reauthors the dedicated-family maps through `scripts/hypertwist_author_higher_dimensional_training_maps.py`, records manifest-backed owned hashes in `docs/generated/higher_dimensional_training_maps/phase6c_dedicated_family_map_manifest.json`, preserves the optional `AHyperTwistHigherDimensionalTrainingShellActor` for manual/editor authoring while intentionally skipping it on the headless commandlet lane because that spawn path still crashes `UnrealEditor-Cmd`, and passes all `13` `HyperTwist.FirstParty.HigherDimensional.Phase6C` tests through pulled proof in `docs/generated/higher_dimensional_training_maps/phase6c_dedicated_map_ownership_automation_report.json`, including `DedicatedMapAuthoringManifest` and `DedicatedMapOwnedShell`. |
|
||||
| Higher-dimensional dedicated-family interactive scene ownership | Implemented now | landed `Phase 6C` interactive-scene and dedicated-host continuation packet | First-party current code now promotes the higher-dimensional runtime-session seams into explicit `Magic120Cell` and `MagicCube5D` interactive-scene surfaces with family-owned runtime-state, projection, and persistence envelopes, replaces the shared-map bridge with dedicated-family training-map assets plus dedicated host/launch/view-context/session surfaces, exposes explicit scene-envelope decoders through the runtime library, batches default selector application so generated-mode launch execution no longer rebuilds repository views once per selector, and then defers repository-view projection across the internal generated-mode handoff itself so only the final launch state forces a view refresh. The primary reverse-SSH `localhost:22022` Windows lane rebuilt the dedicated-family ownership slice on `2026-06-18` with `Result: Succeeded`, UnrealBuildTool `Total execution time: 1648.33 seconds`; the later projection-deferral hardening rebuild completed in `400.77 seconds`, and the exported automation report passed all `11` `HyperTwist.FirstParty.HigherDimensional.Phase6C` tests with `totalDuration` `41.88494873046875`, `LaunchExecution` duration `41.53509521484375`, and the controller quiet interval reduced to `41.52 seconds`. The current recovery-proof continuation now also deterministically reauthors the dedicated-family maps through `scripts/hypertwist_author_higher_dimensional_training_maps.py`, records manifest-backed owned hashes in `docs/generated/higher_dimensional_training_maps/phase6c_dedicated_family_map_manifest.json`, preserves the optional `AHyperTwistHigherDimensionalTrainingShellActor` for manual/editor authoring while intentionally skipping it on the headless commandlet lane because that spawn path still crashes `UnrealEditor-Cmd`, passes all `13` `HyperTwist.FirstParty.HigherDimensional.Phase6C` tests through pulled proof in `docs/generated/higher_dimensional_training_maps/phase6c_dedicated_map_ownership_automation_report.json`, including `DedicatedMapAuthoringManifest` and `DedicatedMapOwnedShell`, and now has a maintained packaged-validation seam through `scripts/Invoke-HyperTwistHigherDimensionalPackage.ps1` plus `scripts/Launch-HyperTwistHigherDimensionalPackage.ps1`. That helper was then live-validated on `2026-06-22` against isolated worktree `C:\HyperTwist_worktrees\phase10validate` in `-SkipBuild` mode, with `BuildCookRun time: 118.53 s`, archive output in `C:\HyperTwist_worktrees\phase10validate_packaged_phase6c_higherdim`, both dedicated-family packaged smoke maps green, and pulled aggregate proof in `docs/generated/higher_dimensional_training_maps/phase6c_dedicated_family_package_validation_report.json`. |
|
||||
| Broad non-Euclidean interaction shell and host ownership | Deep-source grounded retained | `MagicTile` retained remainder | Broad interaction-shell, WinForms/OpenTK host ownership, native spherical/hyperbolic renderer ownership beyond the landed first-party native behavior proof, and generic runtime replacement remain deferred after the landed `Phase 6R-T`, `Phase 7A`, `Phase 7B`, and `Phase 7C` slices, with later native renderer reopening now governed by the separate spec-only `Phase 7D` decision gate. |
|
||||
|
||||
### 7. Speech input and voice sidecars
|
||||
### 8. Speech input and voice sidecars
|
||||
|
||||
| Feature | Status | Primary authority | Notes |
|
||||
|---|---|---|---|
|
||||
|
|
@ -303,7 +313,7 @@ repo.
|
|||
| Broad voice output / narration sidecars | Deep-source grounded retained | `piper`, `coqui-ai/TTS` retained remainder | Keep actual voice-asset shipping, playback/runtime ownership, and broader assistant-platform scope separate from the landed local review and narration packets. |
|
||||
| Actual downloadable model and payload shipping | Deep-source grounded retained | `whisper.cpp` retained remainder | Keep real download/shipping ownership separate from the landed bounded custody boundary and from the code-license judgment. |
|
||||
|
||||
### 8. Provider-neutral AI/provider routing
|
||||
### 9. Provider-neutral AI/provider routing
|
||||
|
||||
| Feature | Status | Primary authority | Notes |
|
||||
|---|---|---|---|
|
||||
|
|
@ -319,7 +329,7 @@ repo.
|
|||
| First-party provider-neutral custom-endpoint runtime routing | Implemented now | landed first-party `Phase 6R-AI` packet | Current bounded per-session provider-profile endpoint selection, runtime routing, fallback-health reflection, and provider-backed transport-failure posture above the landed provider-profile/BYOK and provider-routing seams. OpenAI-compatible custom endpoints are no longer custody-only doctrine targets. |
|
||||
| Provider-specific overlays | Shallow placeholder | future provider-family work only | Keep internal until source-grounded and normalized. |
|
||||
|
||||
### 9. Memory, continuity, notes, and provenance
|
||||
### 10. Memory, continuity, notes, and provenance
|
||||
|
||||
| Feature | Status | Primary authority | Notes |
|
||||
|---|---|---|---|
|
||||
|
|
@ -337,7 +347,7 @@ repo.
|
|||
| User-authored note lane | Deep-source grounded retained | memory doctrine, owner unresolved | Explicit note-lane posture/gating is live through `Phase 6R-M5`, but active user-authored note capture still must not be described as shipped until ownership is justified. |
|
||||
| First-party derived memory and optional adjunct | Implemented now | landed first-party `Phase 6R-M6` packet | Current bounded compact summaries, reduced context packets, and optional digest views are live in explicit derived-only removable form. `Economic-Retention Mode` proves all-features-off correctness by keeping the lane empty and valid, while `Max-Retention Mode` exposes the optional derived adjunct surfaces without promoting them to authoritative truth. |
|
||||
|
||||
### 10. Skills and optional assistive layer
|
||||
### 11. Skills and optional assistive layer
|
||||
|
||||
| Feature | Status | Primary authority | Notes |
|
||||
|---|---|---|---|
|
||||
|
|
|
|||
|
|
@ -78,6 +78,21 @@ Current consolidated milestone snapshot:
|
|||
- the current packaged validation bridge for that lane is source-controlled via
|
||||
`C:\HyperTwist\scripts\Invoke-HyperTwistClassicCubePackage.ps1` and
|
||||
`C:\HyperTwist\scripts\Launch-HyperTwistClassicCubePackage.ps1`
|
||||
- the higher-dimensional dedicated-family lane now also has a maintained
|
||||
packaged validation bridge via
|
||||
`C:\HyperTwist\scripts\Invoke-HyperTwistHigherDimensionalPackage.ps1` and
|
||||
`C:\HyperTwist\scripts\Launch-HyperTwistHigherDimensionalPackage.ps1`,
|
||||
which validate the manifest-backed `Magic120Cell` / `MagicCube5D`
|
||||
dedicated-family map hashes before `BuildCookRun` and emit aggregate plus
|
||||
per-map smoke JSON under the packaged archive `validation` tree
|
||||
- that higher-dimensional package lane was then live-validated on `2026-06-22`
|
||||
through the primary reverse-SSH `localhost:22022` lane against isolated
|
||||
Windows worktree `C:\HyperTwist_worktrees\phase10validate`, using
|
||||
`-SkipBuild` on the already-present game receipt, archive output in
|
||||
`C:\HyperTwist_worktrees\phase10validate_packaged_phase6c_higherdim`,
|
||||
`BuildCookRun time: 118.53 s`, both dedicated-family packaged smoke maps
|
||||
green, and pulled aggregate proof in
|
||||
`docs/generated/higher_dimensional_training_maps/phase6c_dedicated_family_package_validation_report.json`
|
||||
- the earlier dedicated-map package proof used the verified fallback reverse-SSH
|
||||
lane on `localhost:22023`, an isolated Windows worktree at
|
||||
`C:\HyperTwist_worktrees\phase3to5`, authored dedicated classic/follow-along
|
||||
|
|
@ -171,6 +186,37 @@ Current consolidated milestone snapshot:
|
|||
including `CoachDashboard.RuntimeInspectSurface`,
|
||||
`TrainingPanel.RuntimeInspectSurface`, and
|
||||
`Widget.OperatorStatusSurfaceLive`
|
||||
- the public HyperTwist website/distribution lane is now also live through a
|
||||
first-party `website/` app on `2026-06-22`, with rebranded
|
||||
home/about/resources/pricing/download/legal pages, a protected browser
|
||||
operator dashboard, shared SuperTokens auth posture, a browser-to-desktop
|
||||
token handoff seam, a Paddle-ready pricing/check-out structure, and a public
|
||||
`Open Source Notices` page linked from footer/pricing/download surfaces so
|
||||
the current MPL distribution doctrine is not left undocumented on public
|
||||
release surfaces; this lane remains honest that the embedded browser shell is
|
||||
shipping while the optional full-browser simulator path stays spec-only
|
||||
- that same website lane also now has a first-party `website/server`
|
||||
auth/billing backend with `/api/auth/health`, `/api/auth/me`,
|
||||
`/api/auth/logout`, `/api/auth/desktop-link`,
|
||||
`/api/auth/desktop-link/verify`, and `/api/billing/paddle/webhook`, while
|
||||
the browser-to-desktop token lane now also enforces exact website-origin
|
||||
matching, bounded per-user issuance, and one-time verification-token
|
||||
consumption with green `website/server` type-check plus focused tests on
|
||||
`2026-06-22`, and the billing webhook now verifies `Paddle-Signature`
|
||||
against `PADDLE_WEBHOOK_SECRET` through the documented raw-body HMAC flow,
|
||||
and the bounded auth-health probe now truthfully distinguishes configured
|
||||
versus reachable or ready shared SuperTokens core posture while reflecting
|
||||
fallback-active reason back into the protected dashboard rather than
|
||||
hardcoding readiness,
|
||||
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
|
||||
dashboard, and the browser-to-desktop verify handshake; the public
|
||||
`/download` page now routes authenticated users
|
||||
into that protected release surface instead of exposing raw download URLs on
|
||||
the marketing page itself; production checkout URLs, broader operator/admin
|
||||
billing workflows, and the exact public corresponding-source URL remain
|
||||
deployment/application configuration rather than hardcoded product truth
|
||||
- classic-cube `Phase 9A` replay recording is now closed through first-party
|
||||
runtime capture, `.json` replay persistence, local playback reconstruction,
|
||||
schema-light replay normalization across save/load/viewer import, and live
|
||||
|
|
@ -327,6 +373,22 @@ Current consolidated milestone snapshot:
|
|||
the same `localhost:22022` lane passed all `13`
|
||||
`HyperTwist.FirstParty.HigherDimensional.Phase6C` tests, including
|
||||
`DedicatedMapAuthoringManifest` and `DedicatedMapOwnedShell`
|
||||
- the same `Phase 6C` lane now also has a maintained higher-dimensional
|
||||
package-validation helper pair,
|
||||
`scripts/Invoke-HyperTwistHigherDimensionalPackage.ps1` and
|
||||
`scripts/Launch-HyperTwistHigherDimensionalPackage.ps1`, which insist on
|
||||
manifest-backed dedicated-family map freshness before cooking
|
||||
`L_HyperTwist_Magic120CellTraining` and
|
||||
`L_HyperTwist_MagicCube5DTraining` and then emit aggregate plus per-map
|
||||
smoke JSON under the packaged archive `validation` tree
|
||||
- that same higher-dimensional package lane was then live-validated on
|
||||
`2026-06-22` through the primary reverse-SSH `localhost:22022` lane against
|
||||
isolated Windows worktree `C:\HyperTwist_worktrees\phase10validate`, using
|
||||
`-SkipBuild` on the already-present game receipt, archive output in
|
||||
`C:\HyperTwist_worktrees\phase10validate_packaged_phase6c_higherdim`,
|
||||
`BuildCookRun time: 118.53 s`, both dedicated-family packaged smoke maps
|
||||
green, and pulled aggregate proof in
|
||||
`docs/generated/higher_dimensional_training_maps/phase6c_dedicated_family_package_validation_report.json`
|
||||
- the functional `Phase 6C` ownership gap is now closed; the remaining work
|
||||
in this lane is optional further micro-profiling and richer non-headless
|
||||
decorative/manual-authored family map dressing, not missing activation,
|
||||
|
|
|
|||
359
scripts/Invoke-HyperTwistHigherDimensionalPackage.ps1
Normal file
359
scripts/Invoke-HyperTwistHigherDimensionalPackage.ps1
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
param(
|
||||
[string]$ProjectRoot = 'C:\HyperTwist',
|
||||
[string]$ArchiveDirectory = 'C:\HyperTwist\packaged\higher-dimensional',
|
||||
[ValidateSet('Development', 'Shipping')]
|
||||
[string]$Configuration = 'Development',
|
||||
[string]$CookMap = '/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining',
|
||||
[string[]]$AdditionalCookMaps = @('/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining'),
|
||||
[string[]]$SmokeMaps = @(),
|
||||
[string[]]$AdditionalCookerOptions = @(
|
||||
'-DisablePlugins=MovieRenderPipeline',
|
||||
'-SkipCookingEditorContent'
|
||||
),
|
||||
[string]$AuthoringManifestPath = '',
|
||||
[string]$ValidationReportPath = '',
|
||||
[switch]$CleanArchive,
|
||||
[switch]$SkipBuild,
|
||||
[switch]$SkipLaunch
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$RunUatPath = 'C:\Program Files\Epic Games\UE_5.7\Engine\Build\BatchFiles\RunUAT.bat'
|
||||
$UProjectPath = Join-Path $ProjectRoot 'UnrealHyperTwist\UnrealHyperTwist.uproject'
|
||||
$LaunchScriptPath = Join-Path $ProjectRoot 'scripts\Launch-HyperTwistHigherDimensionalPackage.ps1'
|
||||
$GameTargetReceiptPath = Join-Path $ProjectRoot 'UnrealHyperTwist\Binaries\Win64\UnrealHyperTwist.target'
|
||||
$UnrealBuildToolSavedPath = Join-Path $ProjectRoot 'UnrealHyperTwist\Saved\UnrealBuildTool'
|
||||
$CookMaps = @($CookMap) + $AdditionalCookMaps | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
|
||||
$ResolvedSmokeMaps = @(
|
||||
if ($SmokeMaps.Count -gt 0)
|
||||
{
|
||||
$SmokeMaps
|
||||
}
|
||||
else
|
||||
{
|
||||
$CookMaps
|
||||
}
|
||||
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
|
||||
$ResolvedAdditionalCookerOptions = @($AdditionalCookerOptions) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
|
||||
$ValidationRootPath = Join-Path $ArchiveDirectory 'validation'
|
||||
$SmokeReportDirectory = Join-Path $ValidationRootPath 'smoke'
|
||||
|
||||
function Write-Utf8JsonFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[object]$Value
|
||||
)
|
||||
|
||||
$ParentPath = Split-Path -Parent $Path
|
||||
if (-not [string]::IsNullOrWhiteSpace($ParentPath))
|
||||
{
|
||||
New-Item -ItemType Directory -Force -Path $ParentPath | Out-Null
|
||||
}
|
||||
|
||||
$Json = $Value | ConvertTo-Json -Depth 10
|
||||
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom)
|
||||
}
|
||||
|
||||
function Convert-PathToken {
|
||||
param(
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Value))
|
||||
{
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
return ($Value -replace '[\\/:*?"<>| ]', '_')
|
||||
}
|
||||
|
||||
function Read-JsonFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Resolve-PackagedExecutablePath {
|
||||
param(
|
||||
[string]$PackageRoot
|
||||
)
|
||||
|
||||
$CandidateExecutablePaths = @(
|
||||
(Join-Path $PackageRoot 'Windows\UnrealHyperTwist.exe'),
|
||||
(Join-Path $PackageRoot 'WindowsNoEditor\UnrealHyperTwist.exe'),
|
||||
(Join-Path $PackageRoot 'UnrealHyperTwist.exe')
|
||||
)
|
||||
|
||||
return $CandidateExecutablePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
}
|
||||
|
||||
function Convert-GameMapPathToContentPath {
|
||||
param(
|
||||
[string]$RootPath,
|
||||
[string]$GameMapPath
|
||||
)
|
||||
|
||||
if (-not $GameMapPath.StartsWith('/Game/'))
|
||||
{
|
||||
throw "Cook map '$GameMapPath' is not a supported /Game asset path."
|
||||
}
|
||||
|
||||
$RelativeMapPath = $GameMapPath.Substring('/Game/'.Length).Replace('/', '\')
|
||||
return Join-Path $RootPath ("UnrealHyperTwist\Content\{0}.umap" -f $RelativeMapPath)
|
||||
}
|
||||
|
||||
function Assert-CookMapExists {
|
||||
param(
|
||||
[string]$RootPath,
|
||||
[string]$GameMapPath
|
||||
)
|
||||
|
||||
$ExpectedMapPath = Convert-GameMapPathToContentPath -RootPath $RootPath -GameMapPath $GameMapPath
|
||||
if (-not (Test-Path $ExpectedMapPath))
|
||||
{
|
||||
throw "Expected higher-dimensional cook map '$GameMapPath' was not found at '$ExpectedMapPath'. Run 'scripts\Invoke-HyperTwistHigherDimensionalMapAuthoring.ps1' first."
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($AuthoringManifestPath))
|
||||
{
|
||||
$AuthoringManifestPath = Join-Path $ProjectRoot 'docs\generated\higher_dimensional_training_maps\phase6c_dedicated_family_map_manifest.json'
|
||||
}
|
||||
|
||||
if (-not (Test-Path $RunUatPath))
|
||||
{
|
||||
throw "RunUAT was not found at '$RunUatPath'."
|
||||
}
|
||||
|
||||
if (-not (Test-Path $UProjectPath))
|
||||
{
|
||||
throw "UnrealHyperTwist project file was not found at '$UProjectPath'."
|
||||
}
|
||||
|
||||
if (-not (Test-Path $AuthoringManifestPath))
|
||||
{
|
||||
throw "Higher-dimensional authoring manifest was not found at '$AuthoringManifestPath'. Run 'scripts\Invoke-HyperTwistHigherDimensionalMapAuthoring.ps1' first."
|
||||
}
|
||||
|
||||
$AuthoringManifest = Read-JsonFile -Path $AuthoringManifestPath
|
||||
if ($null -eq $AuthoringManifest)
|
||||
{
|
||||
throw "Higher-dimensional authoring manifest '$AuthoringManifestPath' could not be parsed."
|
||||
}
|
||||
|
||||
if ($null -eq $AuthoringManifest.entries -or $AuthoringManifest.entries.Count -lt 2)
|
||||
{
|
||||
throw "Higher-dimensional authoring manifest '$AuthoringManifestPath' does not contain the expected dedicated-family entries."
|
||||
}
|
||||
|
||||
$ManifestEntriesByMapPath = @{}
|
||||
foreach ($ManifestEntry in $AuthoringManifest.entries)
|
||||
{
|
||||
if ([string]::IsNullOrWhiteSpace($ManifestEntry.mapAssetPath))
|
||||
{
|
||||
throw "Higher-dimensional authoring manifest '$AuthoringManifestPath' contains an entry without mapAssetPath."
|
||||
}
|
||||
|
||||
$ManifestEntriesByMapPath[$ManifestEntry.mapAssetPath] = $ManifestEntry
|
||||
}
|
||||
|
||||
$ValidatedManifestEntries = @()
|
||||
foreach ($TargetMap in ($CookMaps + $ResolvedSmokeMaps | Select-Object -Unique))
|
||||
{
|
||||
Assert-CookMapExists -RootPath $ProjectRoot -GameMapPath $TargetMap
|
||||
|
||||
if (-not $ManifestEntriesByMapPath.ContainsKey($TargetMap))
|
||||
{
|
||||
throw "Higher-dimensional cook map '$TargetMap' is not present in '$AuthoringManifestPath'. Keep this helper scoped to the manifest-backed dedicated-family maps."
|
||||
}
|
||||
|
||||
$ManifestEntry = $ManifestEntriesByMapPath[$TargetMap]
|
||||
$ExpectedMapPath = Convert-GameMapPathToContentPath -RootPath $ProjectRoot -GameMapPath $TargetMap
|
||||
$ManifestRelativeMapPath = Join-Path $ProjectRoot ($ManifestEntry.mapFileRelativePath -replace '/', '\')
|
||||
if ($ExpectedMapPath -ne $ManifestRelativeMapPath)
|
||||
{
|
||||
throw "Higher-dimensional manifest entry '$TargetMap' points at '$ManifestRelativeMapPath', but the expected content path resolved to '$ExpectedMapPath'. Re-run dedicated-family map authoring before packaging."
|
||||
}
|
||||
|
||||
if (-not (Test-Path $ManifestRelativeMapPath))
|
||||
{
|
||||
throw "Higher-dimensional manifest-backed map file '$ManifestRelativeMapPath' was not found. Run 'scripts\Invoke-HyperTwistHigherDimensionalMapAuthoring.ps1' first."
|
||||
}
|
||||
|
||||
$CurrentHash = (Get-FileHash $ManifestRelativeMapPath -Algorithm MD5).Hash.ToLowerInvariant()
|
||||
$ExpectedHash = [string]$ManifestEntry.mapHashMd5
|
||||
if ([string]::IsNullOrWhiteSpace($ExpectedHash))
|
||||
{
|
||||
throw "Higher-dimensional manifest entry '$TargetMap' does not record mapHashMd5."
|
||||
}
|
||||
|
||||
if ($CurrentHash -ne $ExpectedHash.ToLowerInvariant())
|
||||
{
|
||||
throw "Higher-dimensional map '$TargetMap' hash drifted from manifest '$AuthoringManifestPath'. Expected '$ExpectedHash', found '$CurrentHash'. Re-run 'scripts\Invoke-HyperTwistHigherDimensionalMapAuthoring.ps1' before packaging."
|
||||
}
|
||||
|
||||
$ValidatedManifestEntries += [ordered]@{
|
||||
mapKind = [string]$ManifestEntry.mapKind
|
||||
familyKey = [string]$ManifestEntry.familyKey
|
||||
mapAssetPath = [string]$ManifestEntry.mapAssetPath
|
||||
mapFileRelativePath = [string]$ManifestEntry.mapFileRelativePath
|
||||
mapHashMd5 = $CurrentHash
|
||||
activationProfileId = [string]$ManifestEntry.activationProfileId
|
||||
runtimeModeId = [string]$ManifestEntry.runtimeModeId
|
||||
projectionProfileId = [string]$ManifestEntry.projectionProfileId
|
||||
primaryPersistenceBoundaryId = [string]$ManifestEntry.primaryPersistenceBoundaryId
|
||||
}
|
||||
}
|
||||
|
||||
if ($SkipBuild -and -not (Test-Path $GameTargetReceiptPath))
|
||||
{
|
||||
throw "SkipBuild was requested, but the packaged-game target receipt was not found at '$GameTargetReceiptPath'. Re-run without -SkipBuild so RunUAT can build the Win64 game target."
|
||||
}
|
||||
|
||||
if ($CleanArchive -and (Test-Path $ArchiveDirectory))
|
||||
{
|
||||
Remove-Item -LiteralPath $ArchiveDirectory -Recurse -Force
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $UnrealBuildToolSavedPath | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $ArchiveDirectory | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $ValidationRootPath | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $SmokeReportDirectory | Out-Null
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ValidationReportPath))
|
||||
{
|
||||
$ValidationReportPath = Join-Path $ValidationRootPath 'higher-dimensional-package-validation-report.json'
|
||||
}
|
||||
|
||||
$RunUatArguments = @(
|
||||
'BuildCookRun',
|
||||
"-project=$UProjectPath",
|
||||
'-noP4',
|
||||
'-platform=Win64',
|
||||
"-clientconfig=$Configuration",
|
||||
'-cook',
|
||||
'-stage',
|
||||
'-package',
|
||||
'-pak',
|
||||
'-archive',
|
||||
"-archivedirectory=$ArchiveDirectory",
|
||||
"-map=$($CookMaps -join '+')",
|
||||
'-unattended',
|
||||
'-utf8output'
|
||||
)
|
||||
|
||||
if (-not $SkipBuild)
|
||||
{
|
||||
$RunUatArguments += '-build'
|
||||
}
|
||||
|
||||
if ($ResolvedAdditionalCookerOptions.Count -gt 0)
|
||||
{
|
||||
# Keep the dedicated-family runtime lane insulated from editor-only plugin/content bleed.
|
||||
$RunUatArguments += "-AdditionalCookerOptions=$($ResolvedAdditionalCookerOptions -join ' ')"
|
||||
}
|
||||
|
||||
$ValidationReport = [ordered]@{
|
||||
reportVersion = 'ht-higher-dimensional-package-validation/v1'
|
||||
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||
projectRoot = $ProjectRoot
|
||||
archiveDirectory = $ArchiveDirectory
|
||||
configuration = $Configuration
|
||||
cookMaps = @($CookMaps)
|
||||
smokeMaps = @($ResolvedSmokeMaps)
|
||||
additionalCookerOptions = @($ResolvedAdditionalCookerOptions)
|
||||
cleanArchive = [bool]$CleanArchive
|
||||
skipBuild = [bool]$SkipBuild
|
||||
skipLaunch = [bool]$SkipLaunch
|
||||
authoringManifest = [ordered]@{
|
||||
path = $AuthoringManifestPath
|
||||
manifestId = [string]$AuthoringManifest.manifestId
|
||||
manifestVersion = [string]$AuthoringManifest.manifestVersion
|
||||
authoredThroughGameModeClassPath = [string]$AuthoringManifest.authoredThroughGameModeClassPath
|
||||
validatedEntries = @($ValidatedManifestEntries)
|
||||
}
|
||||
result = 'failed'
|
||||
packagedExecutablePath = $null
|
||||
smokeReports = @()
|
||||
error = $null
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Write-Host "Packaging HyperTwist higher-dimensional validation lane to '$ArchiveDirectory'..."
|
||||
& $RunUatPath @RunUatArguments
|
||||
|
||||
if ($LASTEXITCODE -ne 0)
|
||||
{
|
||||
throw "RunUAT packaging failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
$PackagedExecutablePath = Resolve-PackagedExecutablePath -PackageRoot $ArchiveDirectory
|
||||
if ($null -eq $PackagedExecutablePath)
|
||||
{
|
||||
throw "No packaged UnrealHyperTwist executable was found beneath '$ArchiveDirectory' after packaging."
|
||||
}
|
||||
|
||||
$ValidationReport.packagedExecutablePath = $PackagedExecutablePath
|
||||
|
||||
if (-not $SkipLaunch)
|
||||
{
|
||||
if (-not (Test-Path $LaunchScriptPath))
|
||||
{
|
||||
throw "Launch script was not found at '$LaunchScriptPath'."
|
||||
}
|
||||
|
||||
foreach ($SmokeMap in $ResolvedSmokeMaps)
|
||||
{
|
||||
$SmokeReportPath = Join-Path $SmokeReportDirectory (
|
||||
'{0}.json' -f (Convert-PathToken -Value $SmokeMap)
|
||||
)
|
||||
Write-Host "Smoke validating packaged higher-dimensional map '$SmokeMap'..."
|
||||
& $LaunchScriptPath -PackageRoot $ArchiveDirectory -MapUrl $SmokeMap -ReportPath $SmokeReportPath
|
||||
if ($LASTEXITCODE -ne 0)
|
||||
{
|
||||
throw "Packaged higher-dimensional smoke launch failed for '$SmokeMap' with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
if (-not (Test-Path $SmokeReportPath))
|
||||
{
|
||||
throw "Packaged higher-dimensional smoke launch for '$SmokeMap' completed without writing the expected report '$SmokeReportPath'."
|
||||
}
|
||||
|
||||
$SmokeReport = Read-JsonFile -Path $SmokeReportPath
|
||||
if ($null -eq $SmokeReport)
|
||||
{
|
||||
throw "Packaged higher-dimensional smoke report '$SmokeReportPath' could not be parsed."
|
||||
}
|
||||
|
||||
if ($SmokeReport.result -ne 'passed')
|
||||
{
|
||||
throw "Packaged higher-dimensional smoke report '$SmokeReportPath' did not record a passed result."
|
||||
}
|
||||
|
||||
if ($SmokeReport.mapUrl -ne $SmokeMap)
|
||||
{
|
||||
throw "Packaged higher-dimensional smoke report '$SmokeReportPath' targeted '$($SmokeReport.mapUrl)' instead of '$SmokeMap'."
|
||||
}
|
||||
|
||||
$ValidationReport.smokeReports += @($SmokeReport)
|
||||
}
|
||||
}
|
||||
|
||||
$ValidationReport.result = 'passed'
|
||||
}
|
||||
catch
|
||||
{
|
||||
$ValidationReport.error = $_.Exception.Message
|
||||
Write-Utf8JsonFile -Path $ValidationReportPath -Value $ValidationReport
|
||||
throw
|
||||
}
|
||||
|
||||
Write-Utf8JsonFile -Path $ValidationReportPath -Value $ValidationReport
|
||||
126
scripts/Launch-HyperTwistHigherDimensionalPackage.ps1
Normal file
126
scripts/Launch-HyperTwistHigherDimensionalPackage.ps1
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
param(
|
||||
[string]$PackageRoot = 'C:\HyperTwist\packaged\higher-dimensional',
|
||||
[string]$MapUrl = '/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining',
|
||||
[int]$SmokeSeconds = 10,
|
||||
[int]$ResX = 1600,
|
||||
[int]$ResY = 900,
|
||||
[string]$ReportPath = '',
|
||||
[switch]$KeepRunning
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-Utf8JsonFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[object]$Value
|
||||
)
|
||||
|
||||
$ParentPath = Split-Path -Parent $Path
|
||||
if (-not [string]::IsNullOrWhiteSpace($ParentPath))
|
||||
{
|
||||
New-Item -ItemType Directory -Force -Path $ParentPath | Out-Null
|
||||
}
|
||||
|
||||
$Json = $Value | ConvertTo-Json -Depth 8
|
||||
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom)
|
||||
}
|
||||
|
||||
$CandidateExecutablePaths = @(
|
||||
(Join-Path $PackageRoot 'Windows\UnrealHyperTwist.exe'),
|
||||
(Join-Path $PackageRoot 'WindowsNoEditor\UnrealHyperTwist.exe'),
|
||||
(Join-Path $PackageRoot 'UnrealHyperTwist.exe')
|
||||
)
|
||||
|
||||
$ExecutablePath = $CandidateExecutablePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if ($null -eq $ExecutablePath)
|
||||
{
|
||||
throw "No packaged UnrealHyperTwist executable was found beneath '$PackageRoot'."
|
||||
}
|
||||
|
||||
$ArgumentList = @(
|
||||
$MapUrl,
|
||||
"-ResX=$ResX",
|
||||
"-ResY=$ResY",
|
||||
'-windowed',
|
||||
'-log'
|
||||
)
|
||||
|
||||
Write-Host "Launching packaged higher-dimensional validation lane from '$ExecutablePath'..."
|
||||
$ResolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path
|
||||
$GeneratedAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||
$Process = $null
|
||||
$Report = [ordered]@{
|
||||
reportVersion = 'ht-higher-dimensional-package-smoke/v1'
|
||||
generatedAtUtc = $GeneratedAtUtc
|
||||
packageRoot = $ResolvedPackageRoot
|
||||
executablePath = $ExecutablePath
|
||||
mapUrl = $MapUrl
|
||||
smokeSeconds = $SmokeSeconds
|
||||
resolution = [ordered]@{
|
||||
width = $ResX
|
||||
height = $ResY
|
||||
}
|
||||
keepRunning = [bool]$KeepRunning
|
||||
result = 'failed'
|
||||
processId = $null
|
||||
processStopped = $false
|
||||
exitCode = $null
|
||||
error = $null
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$Process = Start-Process -FilePath $ExecutablePath -ArgumentList $ArgumentList -PassThru
|
||||
Start-Sleep -Seconds $SmokeSeconds
|
||||
|
||||
$Process.Refresh()
|
||||
$Report.processId = $Process.Id
|
||||
if ($Process.HasExited)
|
||||
{
|
||||
$Report.exitCode = $Process.ExitCode
|
||||
throw "Packaged higher-dimensional executable exited early with code $($Process.ExitCode)."
|
||||
}
|
||||
|
||||
$Report.result = 'passed'
|
||||
}
|
||||
catch
|
||||
{
|
||||
$Report.error = $_.Exception.Message
|
||||
if ($null -ne $Process)
|
||||
{
|
||||
$Process.Refresh()
|
||||
if ($Process.HasExited)
|
||||
{
|
||||
$Report.exitCode = $Process.ExitCode
|
||||
}
|
||||
elseif (-not $KeepRunning)
|
||||
{
|
||||
Stop-Process -Id $Process.Id -Force
|
||||
$Report.processStopped = $true
|
||||
}
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($ReportPath))
|
||||
{
|
||||
Write-Utf8JsonFile -Path $ReportPath -Value $Report
|
||||
}
|
||||
|
||||
throw
|
||||
}
|
||||
|
||||
Write-Host "Packaged higher-dimensional smoke launch succeeded (PID $($Process.Id))."
|
||||
if (-not $KeepRunning)
|
||||
{
|
||||
Stop-Process -Id $Process.Id -Force
|
||||
$Report.processStopped = $true
|
||||
Write-Host 'Stopped packaged higher-dimensional smoke process after successful launch validation.'
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($ReportPath))
|
||||
{
|
||||
Write-Utf8JsonFile -Path $ReportPath -Value $Report
|
||||
}
|
||||
20
website/.env.example
Normal file
20
website/.env.example
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
VITE_SUPERTOKENS_API_DOMAIN=http://localhost:3001
|
||||
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_GITHUB_OAUTH_ENABLED=false
|
||||
VITE_GOOGLE_OAUTH_ENABLED=false
|
||||
VITE_ORCID_OAUTH_ENABLED=false
|
||||
VITE_PUBLIC_DOCS_URL=
|
||||
VITE_RELEASE_NOTES_URL=
|
||||
VITE_SUPPORT_EMAIL=hello@hypertwist.app
|
||||
VITE_WINDOWS_DOWNLOAD_URL=
|
||||
VITE_MAC_DOWNLOAD_URL=
|
||||
VITE_LINUX_DOWNLOAD_URL=
|
||||
VITE_PADDLE_CHECKOUT_URL_OPERATOR=
|
||||
VITE_PADDLE_CHECKOUT_URL_STUDIO=
|
||||
VITE_PLAN_PRICE_OPERATOR=Launch pricing via Paddle
|
||||
VITE_PLAN_PRICE_STUDIO=Contact for launch readiness
|
||||
VITE_MPL_SOURCE_URL=
|
||||
VITE_OPEN_SOURCE_REPO_URL=
|
||||
66
website/README.md
Normal file
66
website/README.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# HyperTwist Website
|
||||
|
||||
First-party `hypertwist.app` surface for HyperTwist:
|
||||
|
||||
- public homepage, about, resources, pricing, support, and legal pages
|
||||
- browser-facing operator/account dashboard
|
||||
- shared SuperTokens auth posture reused from the FamiliarOS and ScriptoriumAI website lane
|
||||
- desktop download posture and desktop-link handshake endpoints
|
||||
- Paddle-ready pricing/check-out wiring
|
||||
- public open-source notices surface required by HyperTwist's MPL distribution doctrine
|
||||
|
||||
## Why this app exists
|
||||
|
||||
HyperTwist already ships an embedded browser runtime inside Unreal under
|
||||
`Content/Browser/`. That runtime is not the same thing as a public website,
|
||||
checkout surface, or browser account shell.
|
||||
|
||||
This `website/` directory is the dedicated public and operator-facing web
|
||||
surface for:
|
||||
|
||||
- marketing and product positioning
|
||||
- account/authentication
|
||||
- billing/pricing posture
|
||||
- browser dashboard access
|
||||
- desktop distribution
|
||||
- public legal and open-source notices
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
cd /home/dev/src/HyperTwist/website
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Frontend default URL:
|
||||
|
||||
- `http://localhost:4273`
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
npm run type-check
|
||||
npm run test
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Related surfaces
|
||||
|
||||
- frontend app: `website/src/`
|
||||
- auth server: `website/server/`
|
||||
- embedded Unreal browser runtime: `Content/Browser/`
|
||||
- HyperTwist feature authority: `docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md`
|
||||
- HyperTwist roadmap authority: `docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md`
|
||||
|
||||
## Deployment note
|
||||
|
||||
Before public launch, configure:
|
||||
|
||||
- SuperTokens frontend/backend env vars
|
||||
- production download URLs
|
||||
- production Paddle checkout URLs
|
||||
- the public corresponding-source URL for MPL-covered shipped material
|
||||
|
||||
Do not launch the public pricing/download pages without a valid open-source
|
||||
notices and corresponding-source destination.
|
||||
23
website/index.html
Normal file
23
website/index.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="HyperTwist is a native cube and hypercube training environment for recognition, replay, coaching, higher-dimensional runtime ownership, and desktop-first operator workflows."
|
||||
/>
|
||||
<meta name="theme-color" content="#0b1020" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>HyperTwist</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2839
website/package-lock.json
generated
Normal file
2839
website/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
35
website/package.json
Normal file
35
website/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "hypertwist-website",
|
||||
"version": "1.0.0",
|
||||
"description": "HyperTwist public website, auth, dashboard, pricing, and download surface.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"supertokens-auth-react": "^0.51.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.0.14",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react-swc": "^3.11.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^5.4.19",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
BIN
website/public/branding/hypertwist-3d-symbol.png
Normal file
BIN
website/public/branding/hypertwist-3d-symbol.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
21
website/server/.env.example
Normal file
21
website/server/.env.example
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
PORT=3001
|
||||
SUPERTOKENS_CORE_URI=http://localhost:3567
|
||||
API_DOMAIN=http://localhost:3001
|
||||
WEBSITE_DOMAIN=http://localhost:4273
|
||||
API_BASE_PATH=/auth
|
||||
WEBSITE_BASE_PATH=/auth
|
||||
COOKIE_SECURE=false
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
DEFAULT_PLAN=free
|
||||
DEFAULT_ROLE=operator
|
||||
SUPERTOKENS_HEALTH_TIMEOUT_MS=2000
|
||||
DESKTOP_LINK_RATE_LIMIT_MAX=5
|
||||
DESKTOP_LINK_RATE_LIMIT_WINDOW_MS=900000
|
||||
PADDLE_WEBHOOK_SECRET=
|
||||
PADDLE_WEBHOOK_TOLERANCE_MS=5000
|
||||
BILLING_STATE_PATH=
|
||||
PADDLE_PRODUCT_PLAN_MAP=
|
||||
PADDLE_PRICE_PLAN_MAP=
|
||||
63
website/server/README.md
Normal file
63
website/server/README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# HyperTwist Auth Server
|
||||
|
||||
Self-hosted SuperTokens auth backend for the HyperTwist website.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- email/password sign-up and sign-in
|
||||
- optional GitHub and Google OAuth
|
||||
- session bootstrap for the browser dashboard
|
||||
- browser-to-desktop token handoff with exact-origin checks, bounded issuance, and one-time verification tokens
|
||||
- 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
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd /home/dev/src/HyperTwist/website/server
|
||||
npm install
|
||||
cp .env.example .env
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The website frontend should point at this backend through:
|
||||
|
||||
```bash
|
||||
VITE_SUPERTOKENS_API_DOMAIN=http://localhost:3001
|
||||
VITE_SUPERTOKENS_WEBSITE_DOMAIN=http://localhost:4273
|
||||
VITE_AUTH_API_BASE_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
## Important note
|
||||
|
||||
`/api/billing/paddle/webhook` now verifies the `Paddle-Signature` header
|
||||
against `PADDLE_WEBHOOK_SECRET` using the documented raw-body `ts:h1`
|
||||
HMAC-SHA256 flow before it accepts the event.
|
||||
|
||||
The desktop-link lane is intentionally stricter than a generic session helper:
|
||||
|
||||
- request origin must match the configured website origin exactly
|
||||
- token issuance is rate-limited per user
|
||||
- verification consumes the token on first successful use
|
||||
- verification now returns resolved download-entitlement posture alongside identity, plan, and role
|
||||
|
||||
The remaining Paddle deployment work is now configuration, not missing server
|
||||
ownership:
|
||||
|
||||
- real production checkout URLs
|
||||
- live notification-destination secret management
|
||||
- event-specific async processing after verification
|
||||
|
||||
The server now also supports a small first-party entitlement store:
|
||||
|
||||
- `BILLING_STATE_PATH` optionally relocates the persisted billing state file
|
||||
- `PADDLE_PRODUCT_PLAN_MAP` can map Paddle product IDs to `free` / `operator` / `studio` / `enterprise`
|
||||
- `PADDLE_PRICE_PLAN_MAP` can map Paddle price IDs to the same plan codes
|
||||
- if neither map is present, the verified webhook lane can still derive plan from `custom_data.plan`
|
||||
|
||||
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
|
||||
3595
website/server/package-lock.json
generated
Normal file
3595
website/server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
website/server/package.json
Normal file
29
website/server/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "hypertwist-auth-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Self-hosted SuperTokens auth backend for the HyperTwist website.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"supertokens-node": "^21.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^24.0.14",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
84
website/server/src/__tests__/auth-health.test.ts
Normal file
84
website/server/src/__tests__/auth-health.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { buildSuperTokensApiVersionUrl, probeSuperTokensCoreHealth } from '../auth-health'
|
||||
|
||||
describe('buildSuperTokensApiVersionUrl', () => {
|
||||
it('preserves a configured core path prefix and appends the API-version route', () => {
|
||||
const result = buildSuperTokensApiVersionUrl({
|
||||
connectionUri: 'http://localhost:3567/core',
|
||||
apiDomain: 'http://localhost:3001',
|
||||
websiteDomain: 'http://localhost:4273',
|
||||
})
|
||||
|
||||
expect(result).toBe('http://localhost:3567/core/apiversion?apiDomain=http%3A%2F%2Flocalhost%3A3001&websiteDomain=http%3A%2F%2Flocalhost%3A4273')
|
||||
})
|
||||
})
|
||||
|
||||
describe('probeSuperTokensCoreHealth', () => {
|
||||
it('reports ready when the core responds with supported versions', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
versions: ['2.7', '2.21', '5.0'],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(probeSuperTokensCoreHealth({
|
||||
connectionUri: 'http://localhost:3567',
|
||||
apiDomain: 'http://localhost:3001',
|
||||
websiteDomain: 'http://localhost:4273',
|
||||
fetchImpl,
|
||||
})).resolves.toEqual({
|
||||
configured: true,
|
||||
reachable: true,
|
||||
ready: true,
|
||||
apiVersion: '5.0',
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports invalid payloads as not ready', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(probeSuperTokensCoreHealth({
|
||||
connectionUri: 'http://localhost:3567',
|
||||
apiDomain: 'http://localhost:3001',
|
||||
websiteDomain: 'http://localhost:4273',
|
||||
fetchImpl,
|
||||
})).resolves.toEqual({
|
||||
configured: true,
|
||||
reachable: false,
|
||||
ready: false,
|
||||
apiVersion: null,
|
||||
error: 'invalid_payload',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports request failures without throwing', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockRejectedValue(new Error('connect ECONNREFUSED'))
|
||||
|
||||
await expect(probeSuperTokensCoreHealth({
|
||||
connectionUri: 'http://localhost:3567',
|
||||
apiDomain: 'http://localhost:3001',
|
||||
websiteDomain: 'http://localhost:4273',
|
||||
fetchImpl,
|
||||
})).resolves.toEqual({
|
||||
configured: true,
|
||||
reachable: false,
|
||||
ready: false,
|
||||
apiVersion: null,
|
||||
error: 'unreachable',
|
||||
})
|
||||
})
|
||||
})
|
||||
210
website/server/src/__tests__/billing-state.test.ts
Normal file
210
website/server/src/__tests__/billing-state.test.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createBillingStateStore } from '../billing-state'
|
||||
|
||||
const tempDirectories: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirectories.length > 0) {
|
||||
const directory = tempDirectories.pop()
|
||||
if (directory) {
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createTempStatePath() {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'hypertwist-billing-state-'))
|
||||
tempDirectories.push(directory)
|
||||
return path.join(directory, 'billing-state.json')
|
||||
}
|
||||
|
||||
describe('createBillingStateStore', () => {
|
||||
it('applies a subscription activation event and resolves download access', () => {
|
||||
const store = createBillingStateStore({
|
||||
statePath: createTempStatePath(),
|
||||
defaultPlan: 'free',
|
||||
defaultRole: 'operator',
|
||||
})
|
||||
|
||||
const result = store.applyVerifiedPaddleEvent(JSON.stringify({
|
||||
event_id: 'evt_activated',
|
||||
event_type: 'subscription.activated',
|
||||
occurred_at: '2026-06-22T02:00:00.000Z',
|
||||
data: {
|
||||
id: 'sub_123',
|
||||
customer_id: 'ctm_123',
|
||||
status: 'active',
|
||||
custom_data: {
|
||||
email: 'operator@hypertwist.app',
|
||||
plan: 'studio',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'applied',
|
||||
reason: 'processed',
|
||||
eventId: 'evt_activated',
|
||||
eventType: 'subscription.activated',
|
||||
email: 'operator@hypertwist.app',
|
||||
})
|
||||
|
||||
expect(store.getEntitlementByEmail('operator@hypertwist.app')).toMatchObject({
|
||||
plan: 'studio',
|
||||
role: 'operator',
|
||||
canDownload: true,
|
||||
accessStatus: 'active',
|
||||
subscriptionId: 'sub_123',
|
||||
customerId: 'ctm_123',
|
||||
source: 'paddle',
|
||||
})
|
||||
})
|
||||
|
||||
it('deduplicates a previously processed event id', () => {
|
||||
const store = createBillingStateStore({
|
||||
statePath: createTempStatePath(),
|
||||
defaultPlan: 'free',
|
||||
defaultRole: 'operator',
|
||||
})
|
||||
|
||||
const rawEvent = JSON.stringify({
|
||||
event_id: 'evt_dupe',
|
||||
event_type: 'subscription.activated',
|
||||
occurred_at: '2026-06-22T02:00:00.000Z',
|
||||
data: {
|
||||
id: 'sub_dupe',
|
||||
status: 'active',
|
||||
custom_data: {
|
||||
email: 'operator@hypertwist.app',
|
||||
plan: 'operator',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.applyVerifiedPaddleEvent(rawEvent).kind).toBe('applied')
|
||||
expect(store.applyVerifiedPaddleEvent(rawEvent)).toEqual({
|
||||
kind: 'duplicate',
|
||||
reason: 'duplicate_event',
|
||||
eventId: 'evt_dupe',
|
||||
eventType: 'subscription.activated',
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a stale event that arrives after a newer one', () => {
|
||||
const store = createBillingStateStore({
|
||||
statePath: createTempStatePath(),
|
||||
defaultPlan: 'free',
|
||||
defaultRole: 'operator',
|
||||
})
|
||||
|
||||
expect(store.applyVerifiedPaddleEvent(JSON.stringify({
|
||||
event_id: 'evt_newer',
|
||||
event_type: 'subscription.activated',
|
||||
occurred_at: '2026-06-22T03:00:00.000Z',
|
||||
data: {
|
||||
id: 'sub_ordering',
|
||||
customer_id: 'ctm_ordering',
|
||||
status: 'active',
|
||||
custom_data: {
|
||||
email: 'operator@hypertwist.app',
|
||||
plan: 'operator',
|
||||
},
|
||||
},
|
||||
})).kind).toBe('applied')
|
||||
|
||||
const staleResult = store.applyVerifiedPaddleEvent(JSON.stringify({
|
||||
event_id: 'evt_older',
|
||||
event_type: 'subscription.canceled',
|
||||
occurred_at: '2026-06-22T02:00:00.000Z',
|
||||
data: {
|
||||
id: 'sub_ordering',
|
||||
customer_id: 'ctm_ordering',
|
||||
status: 'canceled',
|
||||
},
|
||||
}))
|
||||
|
||||
expect(staleResult).toEqual({
|
||||
kind: 'ignored',
|
||||
reason: 'stale_event',
|
||||
eventId: 'evt_older',
|
||||
eventType: 'subscription.canceled',
|
||||
email: 'operator@hypertwist.app',
|
||||
})
|
||||
expect(store.getEntitlementByEmail('operator@hypertwist.app')?.accessStatus).toBe('active')
|
||||
})
|
||||
|
||||
it('resolves plans from configured price mappings and revokes on cancellation', () => {
|
||||
const store = createBillingStateStore({
|
||||
statePath: createTempStatePath(),
|
||||
defaultPlan: 'free',
|
||||
defaultRole: 'operator',
|
||||
pricePlanMap: {
|
||||
pri_operator: 'operator',
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.applyVerifiedPaddleEvent(JSON.stringify({
|
||||
event_id: 'evt_txn',
|
||||
event_type: 'transaction.completed',
|
||||
occurred_at: '2026-06-22T02:00:00.000Z',
|
||||
data: {
|
||||
id: 'txn_123',
|
||||
customer_id: 'ctm_456',
|
||||
custom_data: {
|
||||
email: 'operator@hypertwist.app',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
price: {
|
||||
id: 'pri_operator',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})).kind).toBe('applied')
|
||||
|
||||
expect(store.getEntitlementByEmail('operator@hypertwist.app')).toMatchObject({
|
||||
plan: 'operator',
|
||||
canDownload: true,
|
||||
transactionId: 'txn_123',
|
||||
customerId: 'ctm_456',
|
||||
})
|
||||
|
||||
expect(store.applyVerifiedPaddleEvent(JSON.stringify({
|
||||
event_id: 'evt_cancel',
|
||||
event_type: 'subscription.canceled',
|
||||
occurred_at: '2026-06-22T04:00:00.000Z',
|
||||
data: {
|
||||
id: 'sub_789',
|
||||
customer_id: 'ctm_456',
|
||||
status: 'canceled',
|
||||
},
|
||||
})).kind).toBe('applied')
|
||||
|
||||
expect(store.getEntitlementByEmail('operator@hypertwist.app')).toMatchObject({
|
||||
plan: 'operator',
|
||||
canDownload: false,
|
||||
accessStatus: 'canceled',
|
||||
subscriptionId: 'sub_789',
|
||||
})
|
||||
})
|
||||
|
||||
it('records unsupported payloads as noops without throwing', () => {
|
||||
const store = createBillingStateStore({
|
||||
statePath: createTempStatePath(),
|
||||
defaultPlan: 'free',
|
||||
defaultRole: 'operator',
|
||||
})
|
||||
|
||||
expect(store.applyVerifiedPaddleEvent('{"not":"a-paddle-event"}')).toEqual({
|
||||
kind: 'noop',
|
||||
reason: 'unsupported_payload',
|
||||
eventId: 'unknown',
|
||||
eventType: 'unknown',
|
||||
})
|
||||
})
|
||||
})
|
||||
97
website/server/src/__tests__/paddle-webhook.test.ts
Normal file
97
website/server/src/__tests__/paddle-webhook.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import crypto from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { verifyPaddleWebhookSignature } from '../paddle-webhook'
|
||||
|
||||
function buildSignatureHeader({
|
||||
rawBody,
|
||||
secretKey,
|
||||
timestamp,
|
||||
}: {
|
||||
rawBody: string
|
||||
secretKey: string
|
||||
timestamp: string
|
||||
}) {
|
||||
const signature = crypto
|
||||
.createHmac('sha256', secretKey)
|
||||
.update(`${timestamp}:${rawBody}`)
|
||||
.digest('hex')
|
||||
|
||||
return `ts=${timestamp};h1=${signature}`
|
||||
}
|
||||
|
||||
describe('verifyPaddleWebhookSignature', () => {
|
||||
it('accepts a valid signature built from the raw body', () => {
|
||||
const rawBody = JSON.stringify({ event_id: 'evt_123', event_type: 'transaction.completed' })
|
||||
const timestamp = '1719012345'
|
||||
const secretKey = 'pdl_ntfset_test_secret'
|
||||
|
||||
const result = verifyPaddleWebhookSignature({
|
||||
rawBody,
|
||||
signatureHeader: buildSignatureHeader({ rawBody, secretKey, timestamp }),
|
||||
secretKey,
|
||||
toleranceMs: 5_000,
|
||||
now: () => Number(timestamp) * 1_000 + 1_000,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true, timestamp })
|
||||
})
|
||||
|
||||
it('rejects missing or malformed headers', () => {
|
||||
const rawBody = '{}'
|
||||
const secretKey = 'pdl_ntfset_test_secret'
|
||||
|
||||
expect(verifyPaddleWebhookSignature({
|
||||
rawBody,
|
||||
signatureHeader: '',
|
||||
secretKey,
|
||||
})).toEqual({ ok: false, error: 'missing_signature' })
|
||||
|
||||
expect(verifyPaddleWebhookSignature({
|
||||
rawBody,
|
||||
signatureHeader: 'ts=1719012345',
|
||||
secretKey,
|
||||
})).toEqual({ ok: false, error: 'invalid_signature_header' })
|
||||
})
|
||||
|
||||
it('rejects signatures that are outside the tolerance window', () => {
|
||||
const rawBody = '{}'
|
||||
const timestamp = '1719012345'
|
||||
const secretKey = 'pdl_ntfset_test_secret'
|
||||
|
||||
const result = verifyPaddleWebhookSignature({
|
||||
rawBody,
|
||||
signatureHeader: buildSignatureHeader({ rawBody, secretKey, timestamp }),
|
||||
secretKey,
|
||||
toleranceMs: 5_000,
|
||||
now: () => Number(timestamp) * 1_000 + 6_000,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, error: 'expired_signature' })
|
||||
})
|
||||
|
||||
it('rejects mismatched signatures', () => {
|
||||
const rawBody = '{}'
|
||||
const secretKey = 'pdl_ntfset_test_secret'
|
||||
|
||||
const result = verifyPaddleWebhookSignature({
|
||||
rawBody,
|
||||
signatureHeader: 'ts=1719012345;h1=deadbeef',
|
||||
secretKey,
|
||||
toleranceMs: 5_000,
|
||||
now: () => 1719012345_000 + 1_000,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, error: 'invalid_signature' })
|
||||
})
|
||||
|
||||
it('fails closed when the secret key is missing', () => {
|
||||
const result = verifyPaddleWebhookSignature({
|
||||
rawBody: '{}',
|
||||
signatureHeader: 'ts=1719012345;h1=deadbeef',
|
||||
secretKey: '',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, error: 'missing_secret' })
|
||||
})
|
||||
})
|
||||
138
website/server/src/__tests__/security.test.ts
Normal file
138
website/server/src/__tests__/security.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildAllowedOriginMatcher, createDesktopLinkStore } from '../security'
|
||||
|
||||
describe('buildAllowedOriginMatcher', () => {
|
||||
it('accepts the configured origin and same-origin referrer paths', () => {
|
||||
const matchesAllowedOrigin = buildAllowedOriginMatcher('https://hypertwist.app')
|
||||
|
||||
expect(matchesAllowedOrigin('https://hypertwist.app')).toBe(true)
|
||||
expect(matchesAllowedOrigin('https://hypertwist.app/app/downloads')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects prefix-spoofed or unrelated origins', () => {
|
||||
const matchesAllowedOrigin = buildAllowedOriginMatcher('https://hypertwist.app')
|
||||
|
||||
expect(matchesAllowedOrigin('https://hypertwist.app.evil.example')).toBe(false)
|
||||
expect(matchesAllowedOrigin('https://evil.example/hypertwist.app')).toBe(false)
|
||||
expect(matchesAllowedOrigin('not-a-url')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createDesktopLinkStore', () => {
|
||||
it('issues one-time desktop-link tokens and consumes them on verify', () => {
|
||||
let currentTime = 100
|
||||
let seed = 1
|
||||
const store = createDesktopLinkStore({
|
||||
ttlMs: 60_000,
|
||||
rateLimitMax: 5,
|
||||
rateLimitWindowMs: 60_000,
|
||||
now: () => currentTime,
|
||||
randomBytes: () => Buffer.from((seed++).toString(16).padStart(64, '0'), 'hex'),
|
||||
})
|
||||
|
||||
const issue = store.issueToken({
|
||||
rateLimitKey: 'desktop-link:user-1',
|
||||
userId: 'user-1',
|
||||
email: 'operator@hypertwist.app',
|
||||
plan: 'operator',
|
||||
role: 'operator',
|
||||
canDownload: true,
|
||||
accessStatus: 'active',
|
||||
})
|
||||
|
||||
expect(issue.ok).toBe(true)
|
||||
if (!issue.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const firstVerify = store.verifyAndConsumeToken(issue.token)
|
||||
expect(firstVerify?.email).toBe('operator@hypertwist.app')
|
||||
expect(firstVerify?.canDownload).toBe(true)
|
||||
expect(firstVerify?.accessStatus).toBe('active')
|
||||
expect(store.verifyAndConsumeToken(issue.token)).toBeNull()
|
||||
expect(store.getTokenCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('rate limits repeated issuance inside the configured window', () => {
|
||||
let currentTime = 1_000
|
||||
let seed = 10
|
||||
const store = createDesktopLinkStore({
|
||||
ttlMs: 60_000,
|
||||
rateLimitMax: 2,
|
||||
rateLimitWindowMs: 5_000,
|
||||
now: () => currentTime,
|
||||
randomBytes: () => Buffer.from((seed++).toString(16).padStart(64, '0'), 'hex'),
|
||||
})
|
||||
|
||||
expect(store.issueToken({
|
||||
rateLimitKey: 'desktop-link:user-2',
|
||||
userId: 'user-2',
|
||||
email: 'operator@hypertwist.app',
|
||||
plan: 'operator',
|
||||
role: 'operator',
|
||||
canDownload: true,
|
||||
accessStatus: 'active',
|
||||
}).ok).toBe(true)
|
||||
expect(store.issueToken({
|
||||
rateLimitKey: 'desktop-link:user-2',
|
||||
userId: 'user-2',
|
||||
email: 'operator@hypertwist.app',
|
||||
plan: 'operator',
|
||||
role: 'operator',
|
||||
canDownload: true,
|
||||
accessStatus: 'active',
|
||||
}).ok).toBe(true)
|
||||
expect(store.issueToken({
|
||||
rateLimitKey: 'desktop-link:user-2',
|
||||
userId: 'user-2',
|
||||
email: 'operator@hypertwist.app',
|
||||
plan: 'operator',
|
||||
role: 'operator',
|
||||
canDownload: true,
|
||||
accessStatus: 'active',
|
||||
})).toEqual({ ok: false, error: 'rate_limited' })
|
||||
|
||||
currentTime += 5_001
|
||||
expect(store.issueToken({
|
||||
rateLimitKey: 'desktop-link:user-2',
|
||||
userId: 'user-2',
|
||||
email: 'operator@hypertwist.app',
|
||||
plan: 'operator',
|
||||
role: 'operator',
|
||||
canDownload: true,
|
||||
accessStatus: 'active',
|
||||
}).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects expired or malformed verification tokens', () => {
|
||||
let currentTime = 5_000
|
||||
const store = createDesktopLinkStore({
|
||||
ttlMs: 500,
|
||||
rateLimitMax: 5,
|
||||
rateLimitWindowMs: 60_000,
|
||||
now: () => currentTime,
|
||||
randomBytes: () => Buffer.from('ab'.repeat(32), 'hex'),
|
||||
})
|
||||
|
||||
const issue = store.issueToken({
|
||||
rateLimitKey: 'desktop-link:user-3',
|
||||
userId: 'user-3',
|
||||
email: 'operator@hypertwist.app',
|
||||
plan: 'operator',
|
||||
role: 'operator',
|
||||
canDownload: false,
|
||||
accessStatus: 'paused',
|
||||
})
|
||||
|
||||
expect(store.verifyAndConsumeToken('bad-token')).toBeNull()
|
||||
expect(issue.ok).toBe(true)
|
||||
if (!issue.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
currentTime += 501
|
||||
expect(store.verifyAndConsumeToken(issue.token)).toBeNull()
|
||||
expect(store.getTokenCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
157
website/server/src/auth-health.ts
Normal file
157
website/server/src/auth-health.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
interface SuperTokensCoreHealthProbeOptions {
|
||||
connectionUri: string
|
||||
apiDomain: string
|
||||
websiteDomain: string
|
||||
timeoutMs?: number
|
||||
fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
export interface SuperTokensCoreHealthSnapshot {
|
||||
configured: boolean
|
||||
reachable: boolean
|
||||
ready: boolean
|
||||
apiVersion: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function normalizeTrimmed(value: string) {
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
function parseApiVersions(payload: unknown): string[] {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return []
|
||||
}
|
||||
|
||||
const rawVersions = (payload as { versions?: unknown }).versions
|
||||
if (!Array.isArray(rawVersions)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return rawVersions
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function compareVersionNumbers(left: string, right: string) {
|
||||
const leftParts = left.split('.').map((part) => Number(part))
|
||||
const rightParts = right.split('.').map((part) => Number(part))
|
||||
const maxLength = Math.max(leftParts.length, rightParts.length)
|
||||
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const leftValue = Number.isFinite(leftParts[index]) ? leftParts[index] : 0
|
||||
const rightValue = Number.isFinite(rightParts[index]) ? rightParts[index] : 0
|
||||
if (leftValue > rightValue) return 1
|
||||
if (leftValue < rightValue) return -1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function pickHighestVersion(versions: string[]) {
|
||||
if (versions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return versions.reduce((highest, candidate) => (
|
||||
compareVersionNumbers(candidate, highest) > 0 ? candidate : highest
|
||||
))
|
||||
}
|
||||
|
||||
export function buildSuperTokensApiVersionUrl({
|
||||
connectionUri,
|
||||
apiDomain,
|
||||
websiteDomain,
|
||||
}: Pick<SuperTokensCoreHealthProbeOptions, 'connectionUri' | 'apiDomain' | 'websiteDomain'>) {
|
||||
const url = new URL(normalizeTrimmed(connectionUri))
|
||||
const nextPath = `${url.pathname.replace(/\/+$/, '')}/apiversion`.replace(/\/{2,}/g, '/')
|
||||
url.pathname = nextPath.startsWith('/') ? nextPath : `/${nextPath}`
|
||||
url.search = ''
|
||||
url.searchParams.set('apiDomain', normalizeTrimmed(apiDomain))
|
||||
url.searchParams.set('websiteDomain', normalizeTrimmed(websiteDomain))
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export async function probeSuperTokensCoreHealth({
|
||||
connectionUri,
|
||||
apiDomain,
|
||||
websiteDomain,
|
||||
timeoutMs = 2_000,
|
||||
fetchImpl = fetch,
|
||||
}: SuperTokensCoreHealthProbeOptions): Promise<SuperTokensCoreHealthSnapshot> {
|
||||
const normalizedConnectionUri = normalizeTrimmed(connectionUri)
|
||||
const normalizedApiDomain = normalizeTrimmed(apiDomain)
|
||||
const normalizedWebsiteDomain = normalizeTrimmed(websiteDomain)
|
||||
const configured = Boolean(normalizedConnectionUri && normalizedApiDomain && normalizedWebsiteDomain)
|
||||
|
||||
if (!configured) {
|
||||
return {
|
||||
configured: false,
|
||||
reachable: false,
|
||||
ready: false,
|
||||
apiVersion: null,
|
||||
error: 'not_configured',
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), Math.max(250, timeoutMs))
|
||||
timeout.unref?.()
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(
|
||||
buildSuperTokensApiVersionUrl({
|
||||
connectionUri: normalizedConnectionUri,
|
||||
apiDomain: normalizedApiDomain,
|
||||
websiteDomain: normalizedWebsiteDomain,
|
||||
}),
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
},
|
||||
signal: controller.signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
configured: true,
|
||||
reachable: false,
|
||||
ready: false,
|
||||
apiVersion: null,
|
||||
error: `http_${response.status}`,
|
||||
}
|
||||
}
|
||||
|
||||
const versions = parseApiVersions(await response.json())
|
||||
const apiVersion = pickHighestVersion(versions)
|
||||
if (!apiVersion) {
|
||||
return {
|
||||
configured: true,
|
||||
reachable: false,
|
||||
ready: false,
|
||||
apiVersion: null,
|
||||
error: 'invalid_payload',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
reachable: true,
|
||||
ready: true,
|
||||
apiVersion,
|
||||
error: null,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
configured: true,
|
||||
reachable: false,
|
||||
ready: false,
|
||||
apiVersion: null,
|
||||
error: error instanceof Error && error.name === 'AbortError' ? 'timeout' : 'unreachable',
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
475
website/server/src/billing-state.ts
Normal file
475
website/server/src/billing-state.ts
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export type BillingPlan = 'free' | 'operator' | 'studio' | 'enterprise'
|
||||
export type BillingRole = 'viewer' | 'operator' | 'reviewer' | 'admin'
|
||||
|
||||
export interface BillingEntitlementState {
|
||||
email: string
|
||||
plan: BillingPlan
|
||||
role: BillingRole
|
||||
canDownload: boolean
|
||||
accessStatus: string
|
||||
source: 'paddle'
|
||||
customerId: string | null
|
||||
subscriptionId: string | null
|
||||
transactionId: string | null
|
||||
lastEventId: string
|
||||
lastEventType: string
|
||||
lastEventAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface BillingStateFile {
|
||||
version: 'ht-billing-state/v1'
|
||||
updatedAt: string
|
||||
processedEvents: Record<string, string>
|
||||
accounts: Record<string, BillingEntitlementState>
|
||||
}
|
||||
|
||||
interface PaddleEventEnvelope {
|
||||
event_id: string
|
||||
event_type: string
|
||||
occurred_at: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface BillingStateStoreOptions {
|
||||
statePath: string
|
||||
defaultPlan: BillingPlan
|
||||
defaultRole: BillingRole
|
||||
productPlanMap?: Record<string, BillingPlan>
|
||||
pricePlanMap?: Record<string, BillingPlan>
|
||||
now?: () => Date
|
||||
}
|
||||
|
||||
export interface BillingEventApplyResult {
|
||||
kind: 'applied' | 'duplicate' | 'ignored' | 'noop'
|
||||
reason:
|
||||
| 'processed'
|
||||
| 'duplicate_event'
|
||||
| 'stale_event'
|
||||
| 'unsupported_payload'
|
||||
| 'unresolved_account'
|
||||
eventId: string
|
||||
eventType: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
function normalizeEmail(value: unknown): string {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function normalizePlan(value: unknown, fallback: BillingPlan): BillingPlan {
|
||||
const plan = String(value || '').trim().toLowerCase()
|
||||
if (plan === 'operator' || plan === 'studio' || plan === 'enterprise' || plan === 'free') {
|
||||
return plan
|
||||
}
|
||||
if (plan === 'explorer') {
|
||||
return 'free'
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function normalizeRole(value: unknown, fallback: BillingRole): BillingRole {
|
||||
const role = String(value || '').trim().toLowerCase()
|
||||
if (role === 'viewer' || role === 'operator' || role === 'reviewer' || role === 'admin') {
|
||||
return role
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function ensureParentDirectory(targetPath: string) {
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true })
|
||||
}
|
||||
|
||||
function readJsonFile(targetPath: string): BillingStateFile | null {
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(targetPath, 'utf8')
|
||||
return JSON.parse(raw) as BillingStateFile
|
||||
}
|
||||
|
||||
function createEmptyState(nowIso: string): BillingStateFile {
|
||||
return {
|
||||
version: 'ht-billing-state/v1',
|
||||
updatedAt: nowIso,
|
||||
processedEvents: {},
|
||||
accounts: {},
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonFile(targetPath: string, data: BillingStateFile) {
|
||||
ensureParentDirectory(targetPath)
|
||||
const tempPath = `${targetPath}.tmp`
|
||||
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), 'utf8')
|
||||
fs.renameSync(tempPath, targetPath)
|
||||
}
|
||||
|
||||
function toIsoString(now: Date): string {
|
||||
return now.toISOString()
|
||||
}
|
||||
|
||||
function isLaterEvent(existingOccurredAt: string, nextOccurredAt: string): boolean {
|
||||
return Date.parse(nextOccurredAt) > Date.parse(existingOccurredAt)
|
||||
}
|
||||
|
||||
function readStringAtPath(root: unknown, pathSegments: Array<string | number>): string | null {
|
||||
let current: unknown = root
|
||||
for (const segment of pathSegments) {
|
||||
if (current == null) return null
|
||||
if (typeof segment === 'number') {
|
||||
if (!Array.isArray(current) || current.length <= segment) return null
|
||||
current = current[segment]
|
||||
continue
|
||||
}
|
||||
if (typeof current !== 'object' || !(segment in current)) return null
|
||||
current = (current as Record<string, unknown>)[segment]
|
||||
}
|
||||
|
||||
const value = String(current || '').trim()
|
||||
return value ? value : null
|
||||
}
|
||||
|
||||
function readRecordAtPath(root: unknown, pathSegments: Array<string | number>): Record<string, unknown> | null {
|
||||
let current: unknown = root
|
||||
for (const segment of pathSegments) {
|
||||
if (current == null) return null
|
||||
if (typeof segment === 'number') {
|
||||
if (!Array.isArray(current) || current.length <= segment) return null
|
||||
current = current[segment]
|
||||
continue
|
||||
}
|
||||
if (typeof current !== 'object' || !(segment in current)) return null
|
||||
current = (current as Record<string, unknown>)[segment]
|
||||
}
|
||||
|
||||
return current && typeof current === 'object' && !Array.isArray(current)
|
||||
? current as Record<string, unknown>
|
||||
: null
|
||||
}
|
||||
|
||||
function readEmailFromEventData(data: Record<string, unknown>): string {
|
||||
const candidates = [
|
||||
['customer', 'email'],
|
||||
['customer', 'email_address'],
|
||||
['custom_data', 'email'],
|
||||
['custom_data', 'user_email'],
|
||||
['custom_data', 'account_email'],
|
||||
['custom_data', 'operator_email'],
|
||||
['custom_data', 'hypertwist_account_email'],
|
||||
['billing_details', 'email'],
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const value = readStringAtPath(data, candidate)
|
||||
const email = normalizeEmail(value)
|
||||
if (email) {
|
||||
return email
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function readAllCandidateIds(data: Record<string, unknown>, key: 'subscriptionId' | 'transactionId' | 'customerId') {
|
||||
const pathCandidates: Record<typeof key, Array<Array<string | number>>> = {
|
||||
subscriptionId: [
|
||||
['id'],
|
||||
['subscription_id'],
|
||||
['data', 'id'],
|
||||
],
|
||||
transactionId: [
|
||||
['transaction_id'],
|
||||
['id'],
|
||||
['data', 'transaction_id'],
|
||||
],
|
||||
customerId: [
|
||||
['customer_id'],
|
||||
['customer', 'id'],
|
||||
['data', 'customer_id'],
|
||||
],
|
||||
}
|
||||
|
||||
const values = new Set<string>()
|
||||
for (const candidate of pathCandidates[key]) {
|
||||
const value = readStringAtPath(data, candidate)
|
||||
if (value) {
|
||||
values.add(value)
|
||||
}
|
||||
}
|
||||
return Array.from(values)
|
||||
}
|
||||
|
||||
function readPlanFromCustomData(data: Record<string, unknown>, fallback: BillingPlan): BillingPlan {
|
||||
const customData = readRecordAtPath(data, ['custom_data'])
|
||||
if (!customData) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return normalizePlan(
|
||||
customData.plan
|
||||
?? customData.ht_plan
|
||||
?? customData.hypertwist_plan
|
||||
?? customData.account_plan,
|
||||
fallback,
|
||||
)
|
||||
}
|
||||
|
||||
function readRoleFromCustomData(data: Record<string, unknown>, fallback: BillingRole): BillingRole {
|
||||
const customData = readRecordAtPath(data, ['custom_data'])
|
||||
if (!customData) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return normalizeRole(
|
||||
customData.role
|
||||
?? customData.ht_role
|
||||
?? customData.hypertwist_role
|
||||
?? customData.account_role,
|
||||
fallback,
|
||||
)
|
||||
}
|
||||
|
||||
function resolvePlanFromItemMaps(
|
||||
data: Record<string, unknown>,
|
||||
productPlanMap: Record<string, BillingPlan>,
|
||||
pricePlanMap: Record<string, BillingPlan>,
|
||||
): BillingPlan | null {
|
||||
const items = Array.isArray(data.items) ? data.items : []
|
||||
for (const item of items) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const record = item as Record<string, unknown>
|
||||
const priceId = readStringAtPath(record, ['price', 'id']) ?? readStringAtPath(record, ['price_id'])
|
||||
if (priceId && pricePlanMap[priceId]) {
|
||||
return pricePlanMap[priceId]
|
||||
}
|
||||
|
||||
const productId =
|
||||
readStringAtPath(record, ['product', 'id'])
|
||||
?? readStringAtPath(record, ['product_id'])
|
||||
?? readStringAtPath(record, ['price', 'product_id'])
|
||||
if (productId && productPlanMap[productId]) {
|
||||
return productPlanMap[productId]
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function inferAccessStatus(eventType: string, data: Record<string, unknown>): string {
|
||||
const explicitStatus = readStringAtPath(data, ['status'])
|
||||
if (explicitStatus) {
|
||||
return explicitStatus
|
||||
}
|
||||
|
||||
if (eventType === 'subscription.trialing') return 'trialing'
|
||||
if (eventType === 'subscription.activated' || eventType === 'subscription.resumed') return 'active'
|
||||
if (eventType === 'subscription.paused') return 'paused'
|
||||
if (eventType === 'subscription.canceled') return 'canceled'
|
||||
if (eventType === 'subscription.past_due' || eventType === 'transaction.past_due' || eventType === 'transaction.payment_failed') return 'past_due'
|
||||
if (eventType === 'transaction.completed' || eventType === 'transaction.paid') return 'completed'
|
||||
if (eventType === 'transaction.canceled') return 'canceled'
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function deriveCanDownload(plan: BillingPlan, accessStatus: string): boolean {
|
||||
if (plan === 'free') {
|
||||
return false
|
||||
}
|
||||
|
||||
return accessStatus === 'active'
|
||||
|| accessStatus === 'trialing'
|
||||
|| accessStatus === 'completed'
|
||||
|| accessStatus === 'paid'
|
||||
}
|
||||
|
||||
function parsePaddleEvent(rawBody: string): PaddleEventEnvelope | null {
|
||||
try {
|
||||
const parsed = JSON.parse(rawBody) as Partial<PaddleEventEnvelope>
|
||||
if (
|
||||
!parsed
|
||||
|| typeof parsed !== 'object'
|
||||
|| typeof parsed.event_id !== 'string'
|
||||
|| typeof parsed.event_type !== 'string'
|
||||
|| typeof parsed.occurred_at !== 'string'
|
||||
|| !parsed.data
|
||||
|| typeof parsed.data !== 'object'
|
||||
|| Array.isArray(parsed.data)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed as PaddleEventEnvelope
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function createBillingStateStore({
|
||||
statePath,
|
||||
defaultPlan,
|
||||
defaultRole,
|
||||
productPlanMap = {},
|
||||
pricePlanMap = {},
|
||||
now = () => new Date(),
|
||||
}: BillingStateStoreOptions) {
|
||||
function loadState(): BillingStateFile {
|
||||
const loaded = readJsonFile(statePath)
|
||||
if (!loaded || loaded.version !== 'ht-billing-state/v1') {
|
||||
return createEmptyState(toIsoString(now()))
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
function saveState(state: BillingStateFile) {
|
||||
state.updatedAt = toIsoString(now())
|
||||
writeJsonFile(statePath, state)
|
||||
}
|
||||
|
||||
function findExistingAccount(
|
||||
state: BillingStateFile,
|
||||
email: string,
|
||||
ids: {
|
||||
subscriptionIds: string[]
|
||||
transactionIds: string[]
|
||||
customerIds: string[]
|
||||
},
|
||||
): BillingEntitlementState | null {
|
||||
if (email && state.accounts[email]) {
|
||||
return state.accounts[email]
|
||||
}
|
||||
|
||||
for (const account of Object.values(state.accounts)) {
|
||||
if (
|
||||
(account.subscriptionId && ids.subscriptionIds.includes(account.subscriptionId))
|
||||
|| (account.transactionId && ids.transactionIds.includes(account.transactionId))
|
||||
|| (account.customerId && ids.customerIds.includes(account.customerId))
|
||||
) {
|
||||
return account
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getEntitlementByEmail(email: string): BillingEntitlementState | null {
|
||||
const normalizedEmail = normalizeEmail(email)
|
||||
if (!normalizedEmail) {
|
||||
return null
|
||||
}
|
||||
|
||||
const state = loadState()
|
||||
return state.accounts[normalizedEmail] || null
|
||||
}
|
||||
|
||||
function applyVerifiedPaddleEvent(rawBody: string): BillingEventApplyResult {
|
||||
const envelope = parsePaddleEvent(rawBody)
|
||||
if (!envelope) {
|
||||
return {
|
||||
kind: 'noop',
|
||||
reason: 'unsupported_payload',
|
||||
eventId: 'unknown',
|
||||
eventType: 'unknown',
|
||||
}
|
||||
}
|
||||
|
||||
const state = loadState()
|
||||
if (state.processedEvents[envelope.event_id]) {
|
||||
return {
|
||||
kind: 'duplicate',
|
||||
reason: 'duplicate_event',
|
||||
eventId: envelope.event_id,
|
||||
eventType: envelope.event_type,
|
||||
}
|
||||
}
|
||||
|
||||
const email = readEmailFromEventData(envelope.data)
|
||||
const ids = {
|
||||
subscriptionIds: [
|
||||
readStringAtPath(envelope.data, ['id']) && envelope.event_type.startsWith('subscription.')
|
||||
? String(readStringAtPath(envelope.data, ['id']))
|
||||
: '',
|
||||
...readAllCandidateIds(envelope.data, 'subscriptionId'),
|
||||
].filter(Boolean),
|
||||
transactionIds: [
|
||||
readStringAtPath(envelope.data, ['id']) && envelope.event_type.startsWith('transaction.')
|
||||
? String(readStringAtPath(envelope.data, ['id']))
|
||||
: '',
|
||||
...readAllCandidateIds(envelope.data, 'transactionId'),
|
||||
].filter(Boolean),
|
||||
customerIds: readAllCandidateIds(envelope.data, 'customerId'),
|
||||
}
|
||||
|
||||
const existingAccount = findExistingAccount(state, email, ids)
|
||||
if (existingAccount && !isLaterEvent(existingAccount.lastEventAt, envelope.occurred_at)) {
|
||||
state.processedEvents[envelope.event_id] = envelope.occurred_at
|
||||
saveState(state)
|
||||
return {
|
||||
kind: 'ignored',
|
||||
reason: 'stale_event',
|
||||
eventId: envelope.event_id,
|
||||
eventType: envelope.event_type,
|
||||
email: existingAccount.email,
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedEmail = email || existingAccount?.email || ''
|
||||
if (!resolvedEmail) {
|
||||
state.processedEvents[envelope.event_id] = envelope.occurred_at
|
||||
saveState(state)
|
||||
return {
|
||||
kind: 'noop',
|
||||
reason: 'unresolved_account',
|
||||
eventId: envelope.event_id,
|
||||
eventType: envelope.event_type,
|
||||
}
|
||||
}
|
||||
|
||||
const planFromItems = resolvePlanFromItemMaps(envelope.data, productPlanMap, pricePlanMap)
|
||||
const nextPlan = planFromItems
|
||||
?? readPlanFromCustomData(envelope.data, existingAccount?.plan ?? defaultPlan)
|
||||
const nextRole = readRoleFromCustomData(envelope.data, existingAccount?.role ?? defaultRole)
|
||||
const accessStatus = inferAccessStatus(envelope.event_type, envelope.data)
|
||||
const nextState: BillingEntitlementState = {
|
||||
email: resolvedEmail,
|
||||
plan: nextPlan,
|
||||
role: nextRole,
|
||||
canDownload: deriveCanDownload(nextPlan, accessStatus),
|
||||
accessStatus,
|
||||
source: 'paddle',
|
||||
customerId: ids.customerIds[0] || existingAccount?.customerId || null,
|
||||
subscriptionId: ids.subscriptionIds[0] || existingAccount?.subscriptionId || null,
|
||||
transactionId: ids.transactionIds[0] || existingAccount?.transactionId || null,
|
||||
lastEventId: envelope.event_id,
|
||||
lastEventType: envelope.event_type,
|
||||
lastEventAt: envelope.occurred_at,
|
||||
updatedAt: toIsoString(now()),
|
||||
}
|
||||
|
||||
state.accounts[resolvedEmail] = nextState
|
||||
state.processedEvents[envelope.event_id] = envelope.occurred_at
|
||||
saveState(state)
|
||||
return {
|
||||
kind: 'applied',
|
||||
reason: 'processed',
|
||||
eventId: envelope.event_id,
|
||||
eventType: envelope.event_type,
|
||||
email: resolvedEmail,
|
||||
}
|
||||
}
|
||||
|
||||
function getProcessedEventCount() {
|
||||
return Object.keys(loadState().processedEvents).length
|
||||
}
|
||||
|
||||
return {
|
||||
applyVerifiedPaddleEvent,
|
||||
getEntitlementByEmail,
|
||||
getProcessedEventCount,
|
||||
getStatePath: () => statePath,
|
||||
}
|
||||
}
|
||||
409
website/server/src/index.ts
Normal file
409
website/server/src/index.ts
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
import 'dotenv/config'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import cors from 'cors'
|
||||
import express from 'express'
|
||||
import path from 'node:path'
|
||||
import supertokens from 'supertokens-node'
|
||||
import { errorHandler, middleware, type SessionRequest } from 'supertokens-node/framework/express'
|
||||
import EmailPassword from 'supertokens-node/recipe/emailpassword'
|
||||
import Session from 'supertokens-node/recipe/session'
|
||||
import ThirdParty from 'supertokens-node/recipe/thirdparty'
|
||||
import GithubProvider from 'supertokens-node/lib/build/recipe/thirdparty/providers/github'
|
||||
import GoogleProvider from 'supertokens-node/lib/build/recipe/thirdparty/providers/google'
|
||||
import { probeSuperTokensCoreHealth } from './auth-health'
|
||||
import { createBillingStateStore, type BillingPlan, type BillingRole } from './billing-state'
|
||||
import { verifyPaddleWebhookSignature } from './paddle-webhook'
|
||||
import { buildAllowedOriginMatcher, createDesktopLinkStore } from './security'
|
||||
|
||||
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>
|
||||
|
||||
const PORT = Number(process.env.PORT || 3001)
|
||||
const SUPERTOKENS_CORE_URI = process.env.SUPERTOKENS_CORE_URI || 'http://localhost:3567'
|
||||
const API_DOMAIN = process.env.API_DOMAIN || `http://localhost:${PORT}`
|
||||
const WEBSITE_DOMAIN = process.env.WEBSITE_DOMAIN || 'http://localhost:4273'
|
||||
const API_BASE_PATH = process.env.API_BASE_PATH || '/auth'
|
||||
const WEBSITE_BASE_PATH = process.env.WEBSITE_BASE_PATH || '/auth'
|
||||
const COOKIE_SECURE = String(process.env.COOKIE_SECURE || '').toLowerCase() === 'true'
|
||||
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || ''
|
||||
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || ''
|
||||
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ''
|
||||
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || ''
|
||||
const DEFAULT_PLAN = normalizeBillingPlan(process.env.DEFAULT_PLAN || 'free')
|
||||
const DEFAULT_ROLE = normalizeBillingRole(process.env.DEFAULT_ROLE || 'operator')
|
||||
const SUPERTOKENS_HEALTH_TIMEOUT_MS = Number(process.env.SUPERTOKENS_HEALTH_TIMEOUT_MS || 2_000)
|
||||
|
||||
const DESKTOP_LINK_TTL_MS = 10 * 60 * 1000
|
||||
const DESKTOP_LINK_RATE_LIMIT_MAX = Number(process.env.DESKTOP_LINK_RATE_LIMIT_MAX || 5)
|
||||
const DESKTOP_LINK_RATE_LIMIT_WINDOW_MS = Number(process.env.DESKTOP_LINK_RATE_LIMIT_WINDOW_MS || 15 * 60 * 1000)
|
||||
const PADDLE_WEBHOOK_SECRET = process.env.PADDLE_WEBHOOK_SECRET || ''
|
||||
const PADDLE_WEBHOOK_TOLERANCE_MS = Number(process.env.PADDLE_WEBHOOK_TOLERANCE_MS || 5_000)
|
||||
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)
|
||||
|
||||
function normalizeBillingPlan(value: string): BillingPlan {
|
||||
const plan = value.trim().toLowerCase()
|
||||
if (plan === 'operator' || plan === 'studio' || plan === 'enterprise' || plan === 'free') {
|
||||
return plan
|
||||
}
|
||||
if (plan === 'explorer') {
|
||||
return 'free'
|
||||
}
|
||||
return 'free'
|
||||
}
|
||||
|
||||
function normalizeBillingRole(value: string): BillingRole {
|
||||
const role = value.trim().toLowerCase()
|
||||
if (role === 'viewer' || role === 'operator' || role === 'reviewer' || role === 'admin') {
|
||||
return role
|
||||
}
|
||||
return 'operator'
|
||||
}
|
||||
|
||||
function parseBillingPlanMap(rawValue: string | undefined): Record<string, BillingPlan> {
|
||||
const trimmed = String(rawValue || '').trim()
|
||||
if (!trimmed) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Record<string, string>
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed)
|
||||
.filter(([key]) => String(key || '').trim().length > 0)
|
||||
.map(([key, value]) => [key, normalizeBillingPlan(String(value || ''))]),
|
||||
)
|
||||
} catch {
|
||||
console.warn('[billing] failed to parse plan map JSON; ignoring configured map')
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAccountState(email: string, payload: Record<string, unknown>) {
|
||||
const entitlement = billingStateStore.getEntitlementByEmail(email)
|
||||
const plan = entitlement?.plan ?? normalizeBillingPlan(String(payload.plan || DEFAULT_PLAN))
|
||||
const role = entitlement?.role ?? normalizeBillingRole(String(payload.role || DEFAULT_ROLE))
|
||||
const canDownload = entitlement ? entitlement.canDownload : plan !== 'free'
|
||||
|
||||
return {
|
||||
plan,
|
||||
role,
|
||||
canDownload,
|
||||
billing: {
|
||||
source: entitlement?.source || 'session',
|
||||
access_status: entitlement?.accessStatus || 'session-default',
|
||||
can_download: canDownload,
|
||||
subscription_id: entitlement?.subscriptionId || null,
|
||||
customer_id: entitlement?.customerId || null,
|
||||
transaction_id: entitlement?.transactionId || null,
|
||||
last_event_id: entitlement?.lastEventId || null,
|
||||
last_event_type: entitlement?.lastEventType || null,
|
||||
last_event_at: entitlement?.lastEventAt || null,
|
||||
updated_at: entitlement?.updatedAt || null,
|
||||
state_path: entitlement ? billingStateStore.getStatePath() : null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const recipeList = [
|
||||
EmailPassword.init({
|
||||
signUpFeature: {
|
||||
formFields: [
|
||||
{ id: 'email' },
|
||||
{ id: 'password' },
|
||||
],
|
||||
},
|
||||
override: {
|
||||
apis: (originalImplementation) => ({
|
||||
...originalImplementation,
|
||||
signUpPOST: async (input) => {
|
||||
const response = await originalImplementation.signUpPOST!(input)
|
||||
if (response.status === 'OK') {
|
||||
await response.session.mergeIntoAccessTokenPayload({
|
||||
email: response.user.emails[0],
|
||||
plan: DEFAULT_PLAN,
|
||||
role: DEFAULT_ROLE,
|
||||
})
|
||||
}
|
||||
return response
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
Session.init({
|
||||
cookieSecure: COOKIE_SECURE,
|
||||
cookieSameSite: COOKIE_SECURE ? 'none' : 'lax',
|
||||
}),
|
||||
]
|
||||
|
||||
const thirdPartyProviders = []
|
||||
if (GITHUB_CLIENT_ID && GITHUB_CLIENT_SECRET) {
|
||||
thirdPartyProviders.push(Github({
|
||||
clientId: GITHUB_CLIENT_ID,
|
||||
clientSecret: GITHUB_CLIENT_SECRET,
|
||||
scope: ['read:user', 'user:email'],
|
||||
}))
|
||||
}
|
||||
if (GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET) {
|
||||
thirdPartyProviders.push(Google({
|
||||
clientId: GOOGLE_CLIENT_ID,
|
||||
clientSecret: GOOGLE_CLIENT_SECRET,
|
||||
scope: ['openid', 'email', 'profile'],
|
||||
}))
|
||||
}
|
||||
|
||||
if (thirdPartyProviders.length > 0) {
|
||||
recipeList.splice(1, 0, ThirdParty.init({
|
||||
signInAndUpFeature: {
|
||||
providers: thirdPartyProviders,
|
||||
},
|
||||
override: {
|
||||
apis: (originalImplementation) => ({
|
||||
...originalImplementation,
|
||||
signInUpPOST: async (input) => {
|
||||
const response = await originalImplementation.signInUpPOST!(input)
|
||||
if (response.status === 'OK') {
|
||||
await response.session.mergeIntoAccessTokenPayload({
|
||||
email: response.user.emails[0] || '',
|
||||
plan: DEFAULT_PLAN,
|
||||
role: DEFAULT_ROLE,
|
||||
})
|
||||
}
|
||||
return response
|
||||
},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
supertokens.init({
|
||||
framework: 'express',
|
||||
supertokens: {
|
||||
connectionURI: SUPERTOKENS_CORE_URI,
|
||||
},
|
||||
appInfo: {
|
||||
appName: 'HyperTwist',
|
||||
apiDomain: API_DOMAIN,
|
||||
websiteDomain: WEBSITE_DOMAIN,
|
||||
apiBasePath: API_BASE_PATH,
|
||||
websiteBasePath: WEBSITE_BASE_PATH,
|
||||
},
|
||||
recipeList,
|
||||
})
|
||||
|
||||
const matchesAllowedOrigin = buildAllowedOriginMatcher(WEBSITE_DOMAIN)
|
||||
const desktopLinkStore = createDesktopLinkStore({
|
||||
ttlMs: DESKTOP_LINK_TTL_MS,
|
||||
rateLimitMax: DESKTOP_LINK_RATE_LIMIT_MAX,
|
||||
rateLimitWindowMs: DESKTOP_LINK_RATE_LIMIT_WINDOW_MS,
|
||||
})
|
||||
const billingStateStore = createBillingStateStore({
|
||||
statePath: BILLING_STATE_PATH,
|
||||
defaultPlan: DEFAULT_PLAN,
|
||||
defaultRole: DEFAULT_ROLE,
|
||||
productPlanMap: PADDLE_PRODUCT_PLAN_MAP,
|
||||
pricePlanMap: PADDLE_PRICE_PLAN_MAP,
|
||||
})
|
||||
|
||||
setInterval(() => {
|
||||
desktopLinkStore.cleanupExpiredTokens()
|
||||
}, 60_000).unref()
|
||||
|
||||
const app = express()
|
||||
|
||||
app.post('/api/billing/paddle/webhook', express.raw({ type: 'application/json' }), (req, res) => {
|
||||
const rawBody = Buffer.isBuffer(req.body) ? req.body.toString() : ''
|
||||
const verificationResult = verifyPaddleWebhookSignature({
|
||||
rawBody,
|
||||
signatureHeader: req.headers['paddle-signature'],
|
||||
secretKey: PADDLE_WEBHOOK_SECRET,
|
||||
toleranceMs: PADDLE_WEBHOOK_TOLERANCE_MS,
|
||||
})
|
||||
|
||||
if (!verificationResult.ok) {
|
||||
const statusCode = verificationResult.error === 'missing_secret'
|
||||
? 503
|
||||
: verificationResult.error === 'expired_signature'
|
||||
? 408
|
||||
: 400
|
||||
console.warn('[billing] rejected paddle webhook', {
|
||||
error: verificationResult.error,
|
||||
bodyBytes: Buffer.byteLength(rawBody),
|
||||
})
|
||||
return res.status(statusCode).json({ error: verificationResult.error })
|
||||
}
|
||||
|
||||
const applyResult = billingStateStore.applyVerifiedPaddleEvent(rawBody)
|
||||
console.log('[billing] verified paddle webhook received', {
|
||||
bodyBytes: Buffer.byteLength(rawBody),
|
||||
timestamp: verificationResult.timestamp,
|
||||
applyResult,
|
||||
})
|
||||
return res.json({ received: true, verified: true, apply_result: applyResult })
|
||||
})
|
||||
|
||||
app.use(cors({
|
||||
origin(origin, callback) {
|
||||
if (!origin || matchesAllowedOrigin(origin)) {
|
||||
callback(null, true)
|
||||
return
|
||||
}
|
||||
callback(new Error('forbidden_origin'))
|
||||
},
|
||||
credentials: true,
|
||||
allowedHeaders: ['content-type', ...supertokens.getAllCORSHeaders()],
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
}))
|
||||
|
||||
app.use(express.json())
|
||||
app.use(cookieParser())
|
||||
app.use(middleware())
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ ok: true, service: 'hypertwist-auth-server' })
|
||||
})
|
||||
|
||||
app.get('/api/auth/health', async (_req, res) => {
|
||||
const superTokensHealth = await probeSuperTokensCoreHealth({
|
||||
connectionUri: SUPERTOKENS_CORE_URI,
|
||||
apiDomain: API_DOMAIN,
|
||||
websiteDomain: WEBSITE_DOMAIN,
|
||||
timeoutMs: SUPERTOKENS_HEALTH_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
service: 'hypertwist-auth-server',
|
||||
supertokens: {
|
||||
configured: superTokensHealth.configured,
|
||||
reachable: superTokensHealth.reachable,
|
||||
ready: superTokensHealth.ready,
|
||||
apiVersion: superTokensHealth.apiVersion,
|
||||
error: superTokensHealth.error,
|
||||
oauth: {
|
||||
github: Boolean(GITHUB_CLIENT_ID && GITHUB_CLIENT_SECRET),
|
||||
google: Boolean(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET),
|
||||
},
|
||||
},
|
||||
fallback: {
|
||||
enabled: true,
|
||||
active: !superTokensHealth.ready,
|
||||
reason: superTokensHealth.ready ? null : (superTokensHealth.error || 'not_ready'),
|
||||
},
|
||||
billing: {
|
||||
statePath: billingStateStore.getStatePath(),
|
||||
processedEventCount: billingStateStore.getProcessedEventCount(),
|
||||
pricePlanMapConfigured: Object.keys(PADDLE_PRICE_PLAN_MAP).length > 0,
|
||||
productPlanMapConfigured: Object.keys(PADDLE_PRODUCT_PLAN_MAP).length > 0,
|
||||
webhookSecretConfigured: Boolean(PADDLE_WEBHOOK_SECRET),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/auth/me', async (req: SessionRequest, res) => {
|
||||
const session = await Session.getSession(req, res, { sessionRequired: false })
|
||||
if (!session) {
|
||||
return res.status(401).json({ error: 'unauthorized' })
|
||||
}
|
||||
|
||||
const payload = session.getAccessTokenPayload()
|
||||
const email = String(payload.email || '')
|
||||
if (!email) {
|
||||
return res.status(401).json({ error: 'session_missing_email' })
|
||||
}
|
||||
|
||||
const resolvedAccount = resolveAccountState(email, payload)
|
||||
if (payload.plan !== resolvedAccount.plan || payload.role !== resolvedAccount.role) {
|
||||
await session.mergeIntoAccessTokenPayload({
|
||||
plan: resolvedAccount.plan,
|
||||
role: resolvedAccount.role,
|
||||
})
|
||||
}
|
||||
|
||||
return res.json({
|
||||
user: {
|
||||
id: session.getUserId(),
|
||||
email,
|
||||
name: payload.name || email.split('@')[0],
|
||||
plan: resolvedAccount.plan,
|
||||
role: resolvedAccount.role,
|
||||
can_download: resolvedAccount.canDownload,
|
||||
billing: resolvedAccount.billing,
|
||||
auth_method: 'supertokens',
|
||||
},
|
||||
mode: 'supertokens',
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/auth/logout', async (req: SessionRequest, res) => {
|
||||
const session = await Session.getSession(req, res, { sessionRequired: false })
|
||||
if (session) {
|
||||
await session.revokeSession()
|
||||
}
|
||||
return res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post('/api/auth/desktop-link', async (req: SessionRequest, res) => {
|
||||
const origin = String(req.headers.origin || req.headers.referer || '')
|
||||
if (!matchesAllowedOrigin(origin)) {
|
||||
return res.status(403).json({ error: 'forbidden_origin' })
|
||||
}
|
||||
|
||||
const session = await Session.getSession(req, res, { sessionRequired: false })
|
||||
if (!session) {
|
||||
return res.status(401).json({ error: 'unauthorized' })
|
||||
}
|
||||
|
||||
const payload = session.getAccessTokenPayload()
|
||||
const email = String(payload.email || '')
|
||||
if (!email) {
|
||||
return res.status(401).json({ error: 'session_missing_email' })
|
||||
}
|
||||
const resolvedAccount = resolveAccountState(email, payload)
|
||||
|
||||
const issueResult = desktopLinkStore.issueToken({
|
||||
rateLimitKey: `desktop-link:${session.getUserId()}`,
|
||||
userId: session.getUserId(),
|
||||
email,
|
||||
plan: resolvedAccount.plan,
|
||||
role: resolvedAccount.role,
|
||||
canDownload: resolvedAccount.canDownload,
|
||||
accessStatus: resolvedAccount.billing.access_status,
|
||||
})
|
||||
if (!issueResult.ok) {
|
||||
return res.status(429).json({ error: issueResult.error })
|
||||
}
|
||||
|
||||
return res.json({ ok: true, token: issueResult.token, expires_at: issueResult.expiresAt })
|
||||
})
|
||||
|
||||
app.get('/api/auth/desktop-link/verify', (req, res) => {
|
||||
const token = String(req.query.token || '')
|
||||
const record = desktopLinkStore.verifyAndConsumeToken(token)
|
||||
if (!record) {
|
||||
return res.status(404).json({ error: 'invalid_token' })
|
||||
}
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
email: record.email,
|
||||
plan: record.plan,
|
||||
role: record.role,
|
||||
can_download: record.canDownload,
|
||||
access_status: record.accessStatus,
|
||||
})
|
||||
})
|
||||
|
||||
app.use(errorHandler())
|
||||
|
||||
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' })
|
||||
})
|
||||
|
||||
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}`)
|
||||
})
|
||||
80
website/server/src/paddle-webhook.ts
Normal file
80
website/server/src/paddle-webhook.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import crypto from 'node:crypto'
|
||||
|
||||
export type PaddleWebhookVerificationResult =
|
||||
| { ok: true; timestamp: string }
|
||||
| {
|
||||
ok: false
|
||||
error: 'missing_secret' | 'missing_signature' | 'invalid_signature_header' | 'expired_signature' | 'invalid_signature'
|
||||
}
|
||||
|
||||
interface VerifyPaddleWebhookSignatureInput {
|
||||
rawBody: string
|
||||
signatureHeader: string | string[] | undefined
|
||||
secretKey: string
|
||||
toleranceMs?: number
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
function normalizeSignatureHeaderValue(signatureHeader: string | string[] | undefined): string {
|
||||
if (Array.isArray(signatureHeader)) {
|
||||
return signatureHeader[0] || ''
|
||||
}
|
||||
return String(signatureHeader || '')
|
||||
}
|
||||
|
||||
export function verifyPaddleWebhookSignature({
|
||||
rawBody,
|
||||
signatureHeader,
|
||||
secretKey,
|
||||
toleranceMs = 5_000,
|
||||
now = () => Date.now(),
|
||||
}: VerifyPaddleWebhookSignatureInput): PaddleWebhookVerificationResult {
|
||||
if (!secretKey.trim()) {
|
||||
return { ok: false, error: 'missing_secret' }
|
||||
}
|
||||
|
||||
const normalizedHeader = normalizeSignatureHeaderValue(signatureHeader).trim()
|
||||
if (!normalizedHeader) {
|
||||
return { ok: false, error: 'missing_signature' }
|
||||
}
|
||||
|
||||
const headerParts = new Map<string, string>()
|
||||
for (const part of normalizedHeader.split(';')) {
|
||||
const [key, value] = part.split('=', 2).map((entry) => entry.trim())
|
||||
if (key && value) {
|
||||
headerParts.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = headerParts.get('ts')
|
||||
const providedSignature = headerParts.get('h1')
|
||||
if (!timestamp || !providedSignature) {
|
||||
return { ok: false, error: 'invalid_signature_header' }
|
||||
}
|
||||
|
||||
const timestampMs = Number(timestamp) * 1_000
|
||||
if (!Number.isFinite(timestampMs) || Math.abs(now() - timestampMs) > toleranceMs) {
|
||||
return { ok: false, error: 'expired_signature' }
|
||||
}
|
||||
|
||||
if (!/^[a-f0-9]+$/i.test(providedSignature) || providedSignature.length % 2 !== 0) {
|
||||
return { ok: false, error: 'invalid_signature' }
|
||||
}
|
||||
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', secretKey)
|
||||
.update(`${timestamp}:${rawBody}`)
|
||||
.digest('hex')
|
||||
|
||||
const expectedBuffer = Buffer.from(expectedSignature, 'hex')
|
||||
const providedBuffer = Buffer.from(providedSignature, 'hex')
|
||||
if (expectedBuffer.length !== providedBuffer.length) {
|
||||
return { ok: false, error: 'invalid_signature' }
|
||||
}
|
||||
|
||||
if (!crypto.timingSafeEqual(expectedBuffer, providedBuffer)) {
|
||||
return { ok: false, error: 'invalid_signature' }
|
||||
}
|
||||
|
||||
return { ok: true, timestamp }
|
||||
}
|
||||
158
website/server/src/security.ts
Normal file
158
website/server/src/security.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import crypto from 'node:crypto'
|
||||
|
||||
export interface DesktopLinkRecord {
|
||||
userId: string
|
||||
email: string
|
||||
plan: string
|
||||
role: string
|
||||
canDownload: boolean
|
||||
accessStatus: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
interface DesktopLinkRateLimitBucket {
|
||||
count: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
interface DesktopLinkStoreOptions {
|
||||
ttlMs: number
|
||||
rateLimitMax: number
|
||||
rateLimitWindowMs: number
|
||||
now?: () => number
|
||||
randomBytes?: (size: number) => Buffer
|
||||
}
|
||||
|
||||
interface DesktopLinkIssueInput {
|
||||
rateLimitKey: string
|
||||
userId: string
|
||||
email: string
|
||||
plan: string
|
||||
role: string
|
||||
canDownload: boolean
|
||||
accessStatus: string
|
||||
}
|
||||
|
||||
type DesktopLinkIssueResult =
|
||||
| { ok: true; token: string; expiresAt: string }
|
||||
| { ok: false; error: 'rate_limited' }
|
||||
|
||||
function normalizeOriginCandidate(candidate: string): string | null {
|
||||
const trimmed = candidate.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(trimmed).origin.toLowerCase()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAllowedOriginMatcher(websiteDomain: string) {
|
||||
const allowedOrigin = normalizeOriginCandidate(websiteDomain)
|
||||
if (!allowedOrigin) {
|
||||
throw new Error(`WEBSITE_DOMAIN '${websiteDomain}' must be an absolute origin.`)
|
||||
}
|
||||
|
||||
return (candidate: string): boolean => normalizeOriginCandidate(candidate) === allowedOrigin
|
||||
}
|
||||
|
||||
export function createDesktopLinkStore({
|
||||
ttlMs,
|
||||
rateLimitMax,
|
||||
rateLimitWindowMs,
|
||||
now = () => Date.now(),
|
||||
randomBytes = crypto.randomBytes,
|
||||
}: DesktopLinkStoreOptions) {
|
||||
const desktopLinkTokens = new Map<string, DesktopLinkRecord>()
|
||||
const rateLimitBuckets = new Map<string, DesktopLinkRateLimitBucket>()
|
||||
|
||||
function cleanupExpiredTokens() {
|
||||
const cutoff = now() - ttlMs
|
||||
for (const [token, record] of desktopLinkTokens) {
|
||||
if (record.createdAt < cutoff) {
|
||||
desktopLinkTokens.delete(token)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, bucket] of rateLimitBuckets) {
|
||||
if (bucket.resetAt <= now()) {
|
||||
rateLimitBuckets.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isRateLimited(rateLimitKey: string) {
|
||||
const currentTime = now()
|
||||
const bucket = rateLimitBuckets.get(rateLimitKey)
|
||||
if (!bucket || currentTime >= bucket.resetAt) {
|
||||
rateLimitBuckets.set(rateLimitKey, {
|
||||
count: 1,
|
||||
resetAt: currentTime + rateLimitWindowMs,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
bucket.count += 1
|
||||
return bucket.count > rateLimitMax
|
||||
}
|
||||
|
||||
function issueToken({
|
||||
rateLimitKey,
|
||||
userId,
|
||||
email,
|
||||
plan,
|
||||
role,
|
||||
canDownload,
|
||||
accessStatus,
|
||||
}: DesktopLinkIssueInput): DesktopLinkIssueResult {
|
||||
cleanupExpiredTokens()
|
||||
if (isRateLimited(rateLimitKey)) {
|
||||
return { ok: false, error: 'rate_limited' }
|
||||
}
|
||||
|
||||
const createdAt = now()
|
||||
const token = randomBytes(32).toString('hex')
|
||||
desktopLinkTokens.set(token, {
|
||||
userId,
|
||||
email,
|
||||
plan,
|
||||
role,
|
||||
canDownload,
|
||||
accessStatus,
|
||||
createdAt,
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
token,
|
||||
expiresAt: new Date(createdAt + ttlMs).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function verifyAndConsumeToken(token: string): DesktopLinkRecord | null {
|
||||
cleanupExpiredTokens()
|
||||
|
||||
const normalizedToken = token.trim()
|
||||
if (!/^[a-f0-9]{64}$/i.test(normalizedToken)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = desktopLinkTokens.get(normalizedToken)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
|
||||
desktopLinkTokens.delete(normalizedToken)
|
||||
return record
|
||||
}
|
||||
|
||||
return {
|
||||
cleanupExpiredTokens,
|
||||
issueToken,
|
||||
verifyAndConsumeToken,
|
||||
getTokenCount: () => desktopLinkTokens.size,
|
||||
}
|
||||
}
|
||||
13
website/server/tsconfig.json
Normal file
13
website/server/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
40
website/src/App.tsx
Normal file
40
website/src/App.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { SuperTokensWrapper } from 'supertokens-auth-react'
|
||||
import { PlatformAuthProvider } from './auth/platform-auth'
|
||||
import { isSuperTokensConfigured } from './auth/supertokens-client'
|
||||
import { AppRouteTree } from './router/AppRouteTree'
|
||||
import { ROUTER_FUTURE_FLAGS } from './router/router-future'
|
||||
import './styles/global.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function MaybeSuperTokensWrapper({ children }: { children: React.ReactNode }) {
|
||||
if (!isSuperTokensConfigured()) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return <SuperTokensWrapper>{children}</SuperTokensWrapper>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter future={ROUTER_FUTURE_FLAGS}>
|
||||
<MaybeSuperTokensWrapper>
|
||||
<PlatformAuthProvider>
|
||||
<AppRouteTree />
|
||||
</PlatformAuthProvider>
|
||||
</MaybeSuperTokensWrapper>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
17
website/src/__tests__/auth-env.test.ts
Normal file
17
website/src/__tests__/auth-env.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { getAuthRuntimeConfig } from '../auth/auth-env'
|
||||
|
||||
describe('getAuthRuntimeConfig', () => {
|
||||
it('normalizes loopback origins to the current browser origin', () => {
|
||||
const config = getAuthRuntimeConfig({
|
||||
VITE_SUPERTOKENS_API_DOMAIN: 'http://localhost:3001/',
|
||||
VITE_SUPERTOKENS_WEBSITE_DOMAIN: 'http://127.0.0.1:4273/',
|
||||
VITE_AUTH_API_BASE_URL: 'http://localhost:3001/',
|
||||
}, {
|
||||
locationOrigin: 'http://127.0.0.1:4273',
|
||||
})
|
||||
|
||||
expect(config.superTokensWebsiteDomain).toBe('http://127.0.0.1:4273')
|
||||
expect(config.authApiBaseUrl).toBe('http://localhost:3001')
|
||||
})
|
||||
})
|
||||
13
website/src/__tests__/password-policy.test.ts
Normal file
13
website/src/__tests__/password-policy.test.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { validateStrongPassword } from '../auth/password-policy'
|
||||
|
||||
describe('validateStrongPassword', () => {
|
||||
it('rejects weak passwords', () => {
|
||||
expect(validateStrongPassword('weak')).toBeTruthy()
|
||||
expect(validateStrongPassword('NoSymbols1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('accepts strong passwords', () => {
|
||||
expect(validateStrongPassword('HyperTwist!9')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
181
website/src/auth/auth-api.ts
Normal file
181
website/src/auth/auth-api.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { getAuthRuntimeConfig } from './auth-env'
|
||||
|
||||
const authRuntime = getAuthRuntimeConfig()
|
||||
const DEFAULT_AUTH_API_TIMEOUT_MS = authRuntime.authApiTimeoutMs
|
||||
|
||||
function normalizeBaseUrl(value: unknown) {
|
||||
return String(value || '').trim().replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function normalizePath(path: string) {
|
||||
return path.startsWith('/') ? path : `/${path}`
|
||||
}
|
||||
|
||||
function buildCandidateBaseUrls() {
|
||||
const configured = normalizeBaseUrl(authRuntime.authApiBaseUrl)
|
||||
const origin = normalizeBaseUrl(typeof window !== 'undefined' ? window.location.origin : '')
|
||||
return Array.from(new Set([configured, origin].filter(Boolean)))
|
||||
}
|
||||
|
||||
function buildTimeoutSignal(timeoutMs: number) {
|
||||
const controller = new AbortController()
|
||||
const timeout = window.setTimeout(() => controller.abort(), timeoutMs)
|
||||
return {
|
||||
signal: controller.signal,
|
||||
cleanup() {
|
||||
window.clearTimeout(timeout)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthApiFetchResult {
|
||||
response: Response
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
export interface DesktopLinkPayload {
|
||||
ok: true
|
||||
token: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export interface DesktopLinkVerifyPayload {
|
||||
ok: true
|
||||
email: string
|
||||
plan: string
|
||||
role: string
|
||||
can_download: boolean
|
||||
access_status: string
|
||||
}
|
||||
|
||||
export interface AuthHealthPayload {
|
||||
ok: true
|
||||
service: string
|
||||
supertokens: {
|
||||
configured: boolean
|
||||
reachable: boolean
|
||||
ready: boolean
|
||||
apiVersion?: string | null
|
||||
error?: string | null
|
||||
oauth?: {
|
||||
github: boolean
|
||||
google: boolean
|
||||
}
|
||||
}
|
||||
fallback: {
|
||||
enabled: boolean
|
||||
active?: boolean
|
||||
reason?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiBootstrapUserPayload {
|
||||
user: {
|
||||
id: string
|
||||
email: string
|
||||
name?: string
|
||||
plan?: string
|
||||
role?: string
|
||||
can_download?: boolean
|
||||
billing?: {
|
||||
source?: string
|
||||
access_status?: string
|
||||
can_download?: boolean
|
||||
subscription_id?: string | null
|
||||
customer_id?: string | null
|
||||
transaction_id?: string | null
|
||||
last_event_id?: string | null
|
||||
last_event_type?: string | null
|
||||
last_event_at?: string | null
|
||||
updated_at?: string | null
|
||||
state_path?: string | null
|
||||
}
|
||||
is_admin?: boolean
|
||||
auth_method?: string
|
||||
}
|
||||
mode: string
|
||||
}
|
||||
|
||||
export function buildAuthApiBaseUrls() {
|
||||
return buildCandidateBaseUrls()
|
||||
}
|
||||
|
||||
export async function authApiFetch(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
options?: {
|
||||
timeoutMs?: number
|
||||
baseUrls?: string[]
|
||||
},
|
||||
): Promise<AuthApiFetchResult> {
|
||||
const baseUrls = options?.baseUrls?.length ? options.baseUrls : buildCandidateBaseUrls()
|
||||
const timeoutMs = Math.max(1000, options?.timeoutMs ?? DEFAULT_AUTH_API_TIMEOUT_MS)
|
||||
const normalizedPath = normalizePath(path)
|
||||
|
||||
let lastError: unknown = null
|
||||
for (const baseUrl of baseUrls) {
|
||||
const { signal, cleanup } = buildTimeoutSignal(timeoutMs)
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${normalizedPath}`, {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
signal,
|
||||
})
|
||||
return { response, baseUrl }
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('Auth API unreachable')
|
||||
}
|
||||
|
||||
export async function getCurrentUser() {
|
||||
const result = await authApiFetch('/api/auth/me')
|
||||
return result.response
|
||||
}
|
||||
|
||||
export async function logoutCurrentUser() {
|
||||
const result = await authApiFetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
})
|
||||
return result.response
|
||||
}
|
||||
|
||||
export async function createDesktopLinkToken() {
|
||||
const result = await authApiFetch('/api/auth/desktop-link', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const payload = await result.response.json() as DesktopLinkPayload | { error?: string }
|
||||
if (!result.response.ok || payload == null || (payload as DesktopLinkPayload).ok !== true) {
|
||||
throw new Error((payload as { error?: string }).error || `desktop_link_http_${result.response.status}`)
|
||||
}
|
||||
return payload as DesktopLinkPayload
|
||||
}
|
||||
|
||||
export async function verifyDesktopLinkToken(token: string) {
|
||||
const result = await authApiFetch(`/api/auth/desktop-link/verify?token=${encodeURIComponent(token)}`)
|
||||
const payload = await result.response.json() as DesktopLinkVerifyPayload | { error?: string }
|
||||
if (!result.response.ok || payload == null || (payload as DesktopLinkVerifyPayload).ok !== true) {
|
||||
throw new Error((payload as { error?: string }).error || `desktop_link_verify_http_${result.response.status}`)
|
||||
}
|
||||
return payload as DesktopLinkVerifyPayload
|
||||
}
|
||||
|
||||
export async function getAuthHealth() {
|
||||
const result = await authApiFetch('/api/auth/health')
|
||||
const payload = await result.response.json() as AuthHealthPayload
|
||||
if (!result.response.ok) {
|
||||
throw new Error(`auth_health_http_${result.response.status}`)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
92
website/src/auth/auth-env.ts
Normal file
92
website/src/auth/auth-env.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
function normalizeEnvValue(value: unknown) {
|
||||
return String(value || '').trim().replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function normalizeLoopbackOrigin(configuredOrigin: string, locationOrigin: string) {
|
||||
if (!configuredOrigin || !locationOrigin) return configuredOrigin
|
||||
|
||||
try {
|
||||
const configured = new URL(configuredOrigin)
|
||||
const current = new URL(locationOrigin)
|
||||
const loopbackHosts = new Set(['localhost', '127.0.0.1'])
|
||||
|
||||
if (
|
||||
loopbackHosts.has(configured.hostname)
|
||||
&& loopbackHosts.has(current.hostname)
|
||||
&& configured.protocol === current.protocol
|
||||
&& configured.port === current.port
|
||||
) {
|
||||
return normalizeEnvValue(locationOrigin)
|
||||
}
|
||||
} catch {
|
||||
return configuredOrigin
|
||||
}
|
||||
|
||||
return configuredOrigin
|
||||
}
|
||||
|
||||
export interface AuthEnvLike {
|
||||
[key: string]: string | number | undefined
|
||||
VITE_SUPERTOKENS_API_DOMAIN?: string
|
||||
VITE_SUPERTOKENS_WEBSITE_DOMAIN?: string
|
||||
VITE_SUPERTOKENS_API_BASE_PATH?: string
|
||||
VITE_SUPERTOKENS_WEBSITE_BASE_PATH?: string
|
||||
VITE_AUTH_API_BASE_URL?: string
|
||||
VITE_AUTH_API_TIMEOUT_MS?: string | number
|
||||
}
|
||||
|
||||
export interface AuthRuntimeConfig {
|
||||
superTokensApiDomain: string
|
||||
superTokensWebsiteDomain: string
|
||||
superTokensApiBasePath: string
|
||||
superTokensWebsiteBasePath: string
|
||||
authApiBaseUrl: string
|
||||
authApiTimeoutMs: number
|
||||
}
|
||||
|
||||
export function getAuthRuntimeConfig(
|
||||
env: Partial<AuthEnvLike> = import.meta.env as Partial<AuthEnvLike>,
|
||||
options?: { locationOrigin?: string },
|
||||
): AuthRuntimeConfig {
|
||||
const locationOrigin = normalizeEnvValue(
|
||||
options?.locationOrigin ?? (typeof window !== 'undefined' ? window.location.origin : ''),
|
||||
)
|
||||
|
||||
return {
|
||||
superTokensApiDomain: normalizeLoopbackOrigin(
|
||||
normalizeEnvValue(env.VITE_SUPERTOKENS_API_DOMAIN),
|
||||
locationOrigin,
|
||||
),
|
||||
superTokensWebsiteDomain: normalizeLoopbackOrigin(
|
||||
normalizeEnvValue(env.VITE_SUPERTOKENS_WEBSITE_DOMAIN),
|
||||
locationOrigin,
|
||||
),
|
||||
superTokensApiBasePath: normalizeEnvValue(env.VITE_SUPERTOKENS_API_BASE_PATH) || '/auth',
|
||||
superTokensWebsiteBasePath: normalizeEnvValue(env.VITE_SUPERTOKENS_WEBSITE_BASE_PATH) || '/auth',
|
||||
authApiBaseUrl: normalizeLoopbackOrigin(
|
||||
normalizeEnvValue(env.VITE_AUTH_API_BASE_URL),
|
||||
locationOrigin,
|
||||
),
|
||||
authApiTimeoutMs: Math.max(1000, Number(env.VITE_AUTH_API_TIMEOUT_MS || 8000)),
|
||||
}
|
||||
}
|
||||
|
||||
export function validateAuthRuntimeConfig(
|
||||
env?: Partial<AuthEnvLike>,
|
||||
): { ready: boolean; missing: string[]; warnings: string[] } {
|
||||
const config = getAuthRuntimeConfig(env ?? (import.meta.env as Partial<AuthEnvLike>))
|
||||
const missing: string[] = []
|
||||
if (!config.superTokensApiDomain) missing.push('VITE_SUPERTOKENS_API_DOMAIN')
|
||||
if (!config.superTokensWebsiteDomain) missing.push('VITE_SUPERTOKENS_WEBSITE_DOMAIN')
|
||||
|
||||
const warnings: string[] = []
|
||||
if (!config.authApiBaseUrl) {
|
||||
warnings.push('VITE_AUTH_API_BASE_URL not configured; same-origin auth fallback remains active.')
|
||||
}
|
||||
|
||||
return {
|
||||
ready: missing.length === 0,
|
||||
missing,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
32
website/src/auth/password-policy.ts
Normal file
32
website/src/auth/password-policy.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
const UNSAFE_PASSWORD_SYMBOL_PATTERN = /[\s"'`\\]/
|
||||
const REQUIRED_UPPERCASE_PATTERN = /[A-Z]/
|
||||
const REQUIRED_LOWERCASE_PATTERN = /[a-z]/
|
||||
const REQUIRED_NUMBER_PATTERN = /\d/
|
||||
const REQUIRED_SYMBOL_PATTERN = /[!@#$%^&*()_\-+=[\]{}|;:,.<>/?~]/
|
||||
|
||||
export const PASSWORD_POLICY_HINT =
|
||||
'8+ chars; upper/lower, number, symbol. Avoid spaces, quotes, backticks, backslashes.'
|
||||
|
||||
export const PASSWORD_POLICY_PLACEHOLDER = '8+ chars; upper/lower, number, symbol'
|
||||
|
||||
export function validateStrongPassword(password: string) {
|
||||
const value = String(password || '')
|
||||
|
||||
if (value.length < 8) {
|
||||
return 'Use at least 8 characters.'
|
||||
}
|
||||
if (!REQUIRED_UPPERCASE_PATTERN.test(value) || !REQUIRED_LOWERCASE_PATTERN.test(value)) {
|
||||
return 'Use uppercase and lowercase letters.'
|
||||
}
|
||||
if (!REQUIRED_NUMBER_PATTERN.test(value)) {
|
||||
return 'Add at least one number.'
|
||||
}
|
||||
if (!REQUIRED_SYMBOL_PATTERN.test(value)) {
|
||||
return 'Add at least one symbol.'
|
||||
}
|
||||
if (UNSAFE_PASSWORD_SYMBOL_PATTERN.test(value)) {
|
||||
return 'Avoid spaces, quotes, backticks, and backslashes.'
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
386
website/src/auth/platform-auth.tsx
Normal file
386
website/src/auth/platform-auth.tsx
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { signIn as superTokensSignIn, signUp as superTokensSignUp } from 'supertokens-auth-react/recipe/emailpassword'
|
||||
import { redirectToThirdPartyLogin } from 'supertokens-auth-react/recipe/thirdparty'
|
||||
import { signOut as superTokensSignOut } from 'supertokens-auth-react/recipe/session'
|
||||
import { getCurrentUser, logoutCurrentUser, type ApiBootstrapUserPayload } from './auth-api'
|
||||
import { ensureSuperTokensInit, isSuperTokensConfigured } from './supertokens-client'
|
||||
import { validateStrongPassword } from './password-policy'
|
||||
|
||||
export type AuthMethod = 'supertokens' | 'email' | 'github' | 'google' | 'orcid'
|
||||
export type ColorMode = 'dark' | 'light'
|
||||
export type PlatformRole = 'viewer' | 'operator' | 'reviewer' | 'admin'
|
||||
|
||||
export interface PlatformUser {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
authMethod: AuthMethod
|
||||
plan: 'free' | 'operator' | 'studio' | 'enterprise'
|
||||
role?: PlatformRole
|
||||
isAdmin?: boolean
|
||||
canDownload?: boolean
|
||||
billing?: {
|
||||
source: string
|
||||
accessStatus: string
|
||||
canDownload: boolean
|
||||
subscriptionId?: string | null
|
||||
customerId?: string | null
|
||||
transactionId?: string | null
|
||||
lastEventId?: string | null
|
||||
lastEventType?: string | null
|
||||
lastEventAt?: string | null
|
||||
updatedAt?: string | null
|
||||
statePath?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface PlatformLoginInput {
|
||||
method: AuthMethod
|
||||
email: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
export interface PlatformRegisterInput {
|
||||
method: 'supertokens' | 'email'
|
||||
email: string
|
||||
password: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
interface PlatformAuthContextValue {
|
||||
user: PlatformUser | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
colorMode: ColorMode
|
||||
superTokensConfigured: boolean
|
||||
login: (input: PlatformLoginInput) => Promise<{ ok: boolean; error?: string }>
|
||||
register: (input: PlatformRegisterInput) => Promise<{ ok: boolean; error?: string }>
|
||||
loginWithProvider: (provider: 'github' | 'google' | 'orcid') => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
toggleColorMode: () => void
|
||||
}
|
||||
|
||||
const AUTH_STORAGE_KEY = 'hypertwist.platform.user.v1'
|
||||
const COLOR_MODE_STORAGE_KEY = 'hypertwist.platform.color-mode.v1'
|
||||
const PlatformAuthContext = createContext<PlatformAuthContextValue | null>(null)
|
||||
|
||||
function normalizeEmail(value: unknown) {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function deriveNameFromEmail(email: string) {
|
||||
const userPart = email.split('@')[0] || 'Operator'
|
||||
return userPart
|
||||
.replace(/[._-]+/g, ' ')
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
.trim()
|
||||
}
|
||||
|
||||
function normalizePlan(value: unknown): PlatformUser['plan'] {
|
||||
const plan = String(value || '').trim().toLowerCase()
|
||||
if (plan === 'operator' || plan === 'studio' || plan === 'enterprise' || plan === 'free') {
|
||||
return plan
|
||||
}
|
||||
if (plan === 'student' || plan === 'team' || plan === 'group' || plan === 'scholar') {
|
||||
return 'operator'
|
||||
}
|
||||
return 'free'
|
||||
}
|
||||
|
||||
function normalizeRole(value: unknown): PlatformRole | undefined {
|
||||
const role = String(value || '').trim().toLowerCase()
|
||||
if (role === 'viewer' || role === 'operator' || role === 'reviewer' || role === 'admin') {
|
||||
return role
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readStoredUser(): PlatformUser | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const raw = window.localStorage.getItem(AUTH_STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as PlatformUser
|
||||
if (!parsed?.id || !parsed?.email || !parsed?.authMethod) return null
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredUser(user: PlatformUser | null) {
|
||||
if (typeof window === 'undefined') return
|
||||
if (!user) {
|
||||
window.localStorage.removeItem(AUTH_STORAGE_KEY)
|
||||
return
|
||||
}
|
||||
window.localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(user))
|
||||
}
|
||||
|
||||
function readStoredColorMode(): ColorMode {
|
||||
if (typeof window === 'undefined') return 'dark'
|
||||
const stored = window.localStorage.getItem(COLOR_MODE_STORAGE_KEY)
|
||||
return stored === 'light' ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
function writeStoredColorMode(mode: ColorMode) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(COLOR_MODE_STORAGE_KEY, mode)
|
||||
document.documentElement.dataset.theme = mode
|
||||
}
|
||||
|
||||
function normalizeApiUser(input: ApiBootstrapUserPayload['user'] | undefined, fallbackMethod: AuthMethod): PlatformUser | null {
|
||||
if (!input) return null
|
||||
const id = String(input.id || '').trim()
|
||||
const email = normalizeEmail(input.email)
|
||||
if (!id || !email) return null
|
||||
const role = normalizeRole(input.role)
|
||||
return {
|
||||
id,
|
||||
email,
|
||||
name: String(input.name || '').trim() || deriveNameFromEmail(email),
|
||||
authMethod: String(input.auth_method || '').trim() === 'supertokens' ? 'supertokens' : fallbackMethod,
|
||||
plan: normalizePlan(input.plan),
|
||||
role,
|
||||
isAdmin: input.is_admin === true || role === 'admin',
|
||||
canDownload: input.can_download === true,
|
||||
billing: input.billing ? {
|
||||
source: String(input.billing.source || 'session'),
|
||||
accessStatus: String(input.billing.access_status || 'session-default'),
|
||||
canDownload: input.billing.can_download === true,
|
||||
subscriptionId: input.billing.subscription_id || null,
|
||||
customerId: input.billing.customer_id || null,
|
||||
transactionId: input.billing.transaction_id || null,
|
||||
lastEventId: input.billing.last_event_id || null,
|
||||
lastEventType: input.billing.last_event_type || null,
|
||||
lastEventAt: input.billing.last_event_at || null,
|
||||
updatedAt: input.billing.updated_at || null,
|
||||
statePath: input.billing.state_path || null,
|
||||
} : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function createLocalFallbackUser(email: string, password: string, name?: string): PlatformUser {
|
||||
const safeEmail = normalizeEmail(email)
|
||||
return {
|
||||
id: `local-${btoa(`${safeEmail}:${password.length}`)}`,
|
||||
email: safeEmail,
|
||||
name: String(name || '').trim() || deriveNameFromEmail(safeEmail),
|
||||
authMethod: 'email',
|
||||
plan: 'free',
|
||||
role: 'operator',
|
||||
canDownload: false,
|
||||
billing: {
|
||||
source: 'local-fallback',
|
||||
accessStatus: 'local-fallback',
|
||||
canDownload: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function parseSignInError(response: { status?: string; formFields?: Array<{ error?: string }> }) {
|
||||
if (response.status === 'WRONG_CREDENTIALS_ERROR') return 'Incorrect email or password.'
|
||||
if (response.status === 'FIELD_ERROR') return response.formFields?.[0]?.error || 'Check the highlighted field.'
|
||||
return 'Unable to sign in.'
|
||||
}
|
||||
|
||||
async function parseSignUpError(response: { status?: string; formFields?: Array<{ error?: string }> }) {
|
||||
if (response.status === 'FIELD_ERROR') return response.formFields?.[0]?.error || 'Check the highlighted field.'
|
||||
if (response.status === 'SIGN_UP_NOT_ALLOWED') return 'Sign-up is not allowed right now.'
|
||||
return 'Unable to create your account.'
|
||||
}
|
||||
|
||||
export function PlatformAuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<PlatformUser | null>(() => readStoredUser())
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [colorMode, setColorMode] = useState<ColorMode>(() => readStoredColorMode())
|
||||
const superTokensConfigured = isSuperTokensConfigured()
|
||||
|
||||
useEffect(() => {
|
||||
writeStoredColorMode(colorMode)
|
||||
}, [colorMode])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function bootstrap() {
|
||||
setIsLoading(true)
|
||||
if (superTokensConfigured) {
|
||||
ensureSuperTokensInit()
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getCurrentUser()
|
||||
if (!response.ok) {
|
||||
if (!cancelled) {
|
||||
setUser(readStoredUser())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const payload = await response.json() as ApiBootstrapUserPayload
|
||||
const nextUser = normalizeApiUser(payload.user, 'supertokens')
|
||||
if (!cancelled) {
|
||||
setUser(nextUser)
|
||||
writeStoredUser(nextUser)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setUser(readStoredUser())
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [superTokensConfigured])
|
||||
|
||||
const value = useMemo<PlatformAuthContextValue>(() => ({
|
||||
user,
|
||||
isAuthenticated: Boolean(user),
|
||||
isLoading,
|
||||
colorMode,
|
||||
superTokensConfigured,
|
||||
async login(input) {
|
||||
if (input.method === 'github' || input.method === 'google' || input.method === 'orcid') {
|
||||
await redirectToThirdPartyLogin({ thirdPartyId: input.method })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const safeEmail = normalizeEmail(input.email)
|
||||
|
||||
if (!safeEmail) {
|
||||
return { ok: false, error: 'Email is required.' }
|
||||
}
|
||||
|
||||
if (!superTokensConfigured) {
|
||||
const localUser = createLocalFallbackUser(safeEmail, input.password || '')
|
||||
setUser(localUser)
|
||||
writeStoredUser(localUser)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
ensureSuperTokensInit()
|
||||
const response = await superTokensSignIn({
|
||||
formFields: [
|
||||
{ id: 'email', value: safeEmail },
|
||||
{ id: 'password', value: String(input.password || '') },
|
||||
],
|
||||
}) as { status?: string; formFields?: Array<{ error?: string }> }
|
||||
|
||||
if (response.status !== 'OK') {
|
||||
return { ok: false, error: await parseSignInError(response) }
|
||||
}
|
||||
|
||||
try {
|
||||
const bootstrapResponse = await getCurrentUser()
|
||||
if (!bootstrapResponse.ok) {
|
||||
return { ok: false, error: 'Signed in, but account bootstrap failed.' }
|
||||
}
|
||||
const payload = await bootstrapResponse.json() as ApiBootstrapUserPayload
|
||||
const nextUser = normalizeApiUser(payload.user, 'supertokens')
|
||||
setUser(nextUser)
|
||||
writeStoredUser(nextUser)
|
||||
} catch {
|
||||
return { ok: false, error: 'Signed in, but the account API is unavailable.' }
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
},
|
||||
async register(input) {
|
||||
const safeEmail = normalizeEmail(input.email)
|
||||
const passwordError = validateStrongPassword(input.password)
|
||||
if (!safeEmail) {
|
||||
return { ok: false, error: 'Email is required.' }
|
||||
}
|
||||
if (passwordError) {
|
||||
return { ok: false, error: passwordError }
|
||||
}
|
||||
|
||||
if (!superTokensConfigured) {
|
||||
const localUser = createLocalFallbackUser(safeEmail, input.password, input.name)
|
||||
setUser(localUser)
|
||||
writeStoredUser(localUser)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
ensureSuperTokensInit()
|
||||
const response = await superTokensSignUp({
|
||||
formFields: [
|
||||
{ id: 'email', value: safeEmail },
|
||||
{ id: 'password', value: input.password },
|
||||
],
|
||||
}) as { status?: string; formFields?: Array<{ error?: string }> }
|
||||
|
||||
if (response.status !== 'OK') {
|
||||
return { ok: false, error: await parseSignUpError(response) }
|
||||
}
|
||||
|
||||
try {
|
||||
const bootstrapResponse = await getCurrentUser()
|
||||
if (!bootstrapResponse.ok) {
|
||||
return { ok: false, error: 'Account created, but bootstrap failed.' }
|
||||
}
|
||||
const payload = await bootstrapResponse.json() as ApiBootstrapUserPayload
|
||||
const nextUser = normalizeApiUser(payload.user, 'supertokens')
|
||||
setUser(nextUser)
|
||||
writeStoredUser(nextUser)
|
||||
} catch {
|
||||
return { ok: false, error: 'Account created, but the account API is unavailable.' }
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
},
|
||||
async loginWithProvider(provider) {
|
||||
if (!superTokensConfigured) {
|
||||
return
|
||||
}
|
||||
ensureSuperTokensInit()
|
||||
await redirectToThirdPartyLogin({ thirdPartyId: provider })
|
||||
},
|
||||
async logout() {
|
||||
writeStoredUser(null)
|
||||
setUser(null)
|
||||
if (!superTokensConfigured) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await logoutCurrentUser()
|
||||
} catch {
|
||||
// Keep local logout deterministic even if the API is unavailable.
|
||||
}
|
||||
try {
|
||||
await superTokensSignOut()
|
||||
} catch {
|
||||
// Session revocation can already have happened server-side.
|
||||
}
|
||||
},
|
||||
toggleColorMode() {
|
||||
setColorMode((current) => current === 'dark' ? 'light' : 'dark')
|
||||
},
|
||||
}), [colorMode, isLoading, superTokensConfigured, user])
|
||||
|
||||
return <PlatformAuthContext.Provider value={value}>{children}</PlatformAuthContext.Provider>
|
||||
}
|
||||
|
||||
export function usePlatformAuth() {
|
||||
const context = useContext(PlatformAuthContext)
|
||||
if (!context) {
|
||||
throw new Error('usePlatformAuth must be used within PlatformAuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
158
website/src/auth/supertokens-client.ts
Normal file
158
website/src/auth/supertokens-client.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import SuperTokens from 'supertokens-auth-react'
|
||||
import EmailPassword from 'supertokens-auth-react/recipe/emailpassword'
|
||||
import ThirdParty, { Github, Google } from 'supertokens-auth-react/recipe/thirdparty'
|
||||
import Session from 'supertokens-auth-react/recipe/session'
|
||||
import { getAuthRuntimeConfig, validateAuthRuntimeConfig } from './auth-env'
|
||||
import { PASSWORD_POLICY_HINT, PASSWORD_POLICY_PLACEHOLDER, validateStrongPassword } from './password-policy'
|
||||
|
||||
const authRuntime = getAuthRuntimeConfig()
|
||||
const authRuntimeValidation = validateAuthRuntimeConfig()
|
||||
const SUPERTOKENS_API_DOMAIN = authRuntime.superTokensApiDomain
|
||||
const SUPERTOKENS_WEBSITE_DOMAIN = authRuntime.superTokensWebsiteDomain
|
||||
const SUPERTOKENS_API_BASE_PATH = authRuntime.superTokensApiBasePath
|
||||
const SUPERTOKENS_WEBSITE_BASE_PATH = authRuntime.superTokensWebsiteBasePath
|
||||
|
||||
const GITHUB_OAUTH_ENABLED = String((import.meta.env as Record<string, string>).VITE_GITHUB_OAUTH_ENABLED || '').toLowerCase() === 'true'
|
||||
const GOOGLE_OAUTH_ENABLED = String((import.meta.env as Record<string, string>).VITE_GOOGLE_OAUTH_ENABLED || '').toLowerCase() === 'true'
|
||||
const ORCID_OAUTH_ENABLED = String((import.meta.env as Record<string, string>).VITE_ORCID_OAUTH_ENABLED || '').toLowerCase() === 'true'
|
||||
|
||||
let initialized = false
|
||||
|
||||
function readRequestedPostAuthPath() {
|
||||
if (typeof window === 'undefined') return null
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const requestedPath = String(params.get('next') || '').trim()
|
||||
if (!requestedPath || !requestedPath.startsWith('/') || requestedPath.startsWith('//')) {
|
||||
return null
|
||||
}
|
||||
return requestedPath
|
||||
}
|
||||
|
||||
const SUPERTOKENS_BRAND_STYLE = `
|
||||
[data-supertokens~="container"] {
|
||||
--palette-background: 11, 16, 32;
|
||||
--palette-inputBackground: 16, 22, 44;
|
||||
--palette-inputBorder: 84, 203, 255;
|
||||
--palette-primary: 247, 178, 103;
|
||||
--palette-primaryBorder: 247, 178, 103;
|
||||
--palette-success: 158, 255, 199;
|
||||
--palette-successBackground: 10, 62, 47;
|
||||
--palette-error: 248, 113, 113;
|
||||
--palette-errorBackground: 69, 10, 10;
|
||||
--palette-textTitle: 244, 246, 248;
|
||||
--palette-textLabel: 212, 225, 245;
|
||||
--palette-textInput: 247, 249, 252;
|
||||
--palette-textPrimary: 247, 178, 103;
|
||||
--palette-textLink: 84, 203, 255;
|
||||
--palette-buttonText: 11, 16, 32;
|
||||
--palette-superTokensBrandingBackground: 11, 16, 32;
|
||||
--palette-superTokensBrandingText: 212, 225, 245;
|
||||
width: min(460px, calc(100vw - 2rem));
|
||||
margin-top: 0;
|
||||
border: 1px solid rgba(84, 203, 255, 0.24) !important;
|
||||
border-radius: 24px !important;
|
||||
background: rgba(12, 18, 35, 0.94) !important;
|
||||
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.55) !important;
|
||||
}
|
||||
[data-supertokens~="button"] {
|
||||
min-height: 44px;
|
||||
border-radius: 999px !important;
|
||||
background: #f7b267 !important;
|
||||
border-color: #f7b267 !important;
|
||||
color: #0b1020 !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
[data-supertokens~="button"]:hover {
|
||||
background: #ffc788 !important;
|
||||
}
|
||||
[data-supertokens~="inputWrapper"] {
|
||||
min-height: 44px;
|
||||
border-radius: 14px !important;
|
||||
background: rgba(14, 21, 40, 0.92) !important;
|
||||
border: 1px solid rgba(84, 203, 255, 0.28) !important;
|
||||
color: #f8fafc !important;
|
||||
}
|
||||
`
|
||||
|
||||
export function isSuperTokensConfigured() {
|
||||
return Boolean(SUPERTOKENS_API_DOMAIN)
|
||||
}
|
||||
|
||||
export function isGitHubOAuthEnabled() {
|
||||
return GITHUB_OAUTH_ENABLED
|
||||
}
|
||||
|
||||
export function isGoogleOAuthEnabled() {
|
||||
return GOOGLE_OAUTH_ENABLED
|
||||
}
|
||||
|
||||
export function isOrcidOAuthEnabled() {
|
||||
return ORCID_OAUTH_ENABLED
|
||||
}
|
||||
|
||||
export function getSuperTokensAuthRuntimeValidation() {
|
||||
return authRuntimeValidation
|
||||
}
|
||||
|
||||
export function ensureSuperTokensInit() {
|
||||
if (initialized || !isSuperTokensConfigured() || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const thirdPartyProviders = []
|
||||
if (GITHUB_OAUTH_ENABLED) thirdPartyProviders.push(Github.init())
|
||||
if (GOOGLE_OAUTH_ENABLED) thirdPartyProviders.push(Google.init())
|
||||
|
||||
const recipeList: Parameters<typeof SuperTokens.init>[0]['recipeList'] = [
|
||||
EmailPassword.init({
|
||||
signInAndUpFeature: {
|
||||
signInForm: {
|
||||
formFields: [
|
||||
{ id: 'email', label: 'Email', placeholder: 'operator@hypertwist.app' },
|
||||
{ id: 'password', label: 'Password', placeholder: 'Your password' },
|
||||
],
|
||||
},
|
||||
signUpForm: {
|
||||
formFields: [
|
||||
{ id: 'email', label: 'Email', placeholder: 'operator@hypertwist.app' },
|
||||
{
|
||||
id: 'password',
|
||||
label: `Password (${PASSWORD_POLICY_HINT})`,
|
||||
placeholder: PASSWORD_POLICY_PLACEHOLDER,
|
||||
validate: async (value: unknown) => validateStrongPassword(String(value || '')),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
Session.init(),
|
||||
]
|
||||
|
||||
if (thirdPartyProviders.length > 0) {
|
||||
recipeList.splice(1, 0, ThirdParty.init({
|
||||
signInAndUpFeature: {
|
||||
providers: thirdPartyProviders,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
SuperTokens.init({
|
||||
appInfo: {
|
||||
appName: 'HyperTwist',
|
||||
apiDomain: SUPERTOKENS_API_DOMAIN,
|
||||
websiteDomain: SUPERTOKENS_WEBSITE_DOMAIN || window.location.origin,
|
||||
apiBasePath: SUPERTOKENS_API_BASE_PATH,
|
||||
websiteBasePath: SUPERTOKENS_WEBSITE_BASE_PATH,
|
||||
},
|
||||
recipeList,
|
||||
style: SUPERTOKENS_BRAND_STYLE,
|
||||
getRedirectionURL: async (context) => {
|
||||
if (context.action === 'SUCCESS') {
|
||||
return readRequestedPostAuthPath() || '/app'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
initialized = true
|
||||
}
|
||||
74
website/src/components/layout/AppShell.tsx
Normal file
74
website/src/components/layout/AppShell.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { Gauge, KeyRound, MonitorSmartphone, PackageOpen, ShieldCheck } from 'lucide-react'
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { usePlatformAuth } from '../../auth/platform-auth'
|
||||
import { brandConfig } from '../../site-config'
|
||||
|
||||
const appLinks = [
|
||||
{ to: '/app', label: 'Overview', icon: Gauge, end: true },
|
||||
{ to: '/app/downloads', label: 'Downloads', icon: MonitorSmartphone },
|
||||
{ to: '/app/browser-access', label: 'Browser Access', icon: PackageOpen },
|
||||
{ to: '/app/account', label: 'Account', icon: KeyRound },
|
||||
{ to: '/app/notices', label: 'Notices', icon: ShieldCheck },
|
||||
] as const
|
||||
|
||||
export function AppShell() {
|
||||
const { user, logout, colorMode, toggleColorMode } = usePlatformAuth()
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="app-sidebar">
|
||||
<div className="app-sidebar__brand">
|
||||
<img
|
||||
src="/branding/hypertwist-3d-symbol.png"
|
||||
alt="HyperTwist"
|
||||
className="app-sidebar__brand-image"
|
||||
/>
|
||||
<div>
|
||||
<strong>{brandConfig.brandName}</strong>
|
||||
<p>Browser operator shell</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="app-nav" aria-label="Operator routes">
|
||||
{appLinks.map((link) => {
|
||||
const Icon = link.icon
|
||||
return (
|
||||
<NavLink
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
end={link.to === '/app'}
|
||||
className={({ isActive }) => `app-nav__link${isActive ? ' is-active' : ''}`}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{link.label}</span>
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="app-sidebar__footer">
|
||||
<button type="button" className="button button--ghost button--full" onClick={toggleColorMode}>
|
||||
Switch to {colorMode === 'dark' ? 'light' : 'dark'} mode
|
||||
</button>
|
||||
<button type="button" className="button button--ghost button--full" onClick={() => void logout()}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="app-main-shell">
|
||||
<header className="app-topbar">
|
||||
<div>
|
||||
<p className="eyebrow">Operator access</p>
|
||||
<h1>HyperTwist dashboard</h1>
|
||||
</div>
|
||||
<div className="app-topbar__user">
|
||||
<strong>{user?.name || 'Operator'}</strong>
|
||||
<span>{user?.plan || 'free'} plan</span>
|
||||
</div>
|
||||
</header>
|
||||
<main className="app-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
website/src/components/layout/MarketingShell.tsx
Normal file
89
website/src/components/layout/MarketingShell.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { Link, NavLink } from 'react-router-dom'
|
||||
import { usePlatformAuth } from '../../auth/platform-auth'
|
||||
import { brandConfig } from '../../site-config'
|
||||
import { footerLinks } from '../../site-data'
|
||||
|
||||
const marketingLinks = [
|
||||
{ to: '/', label: 'Home' },
|
||||
{ to: '/about', label: 'About' },
|
||||
{ to: '/resources', label: 'Resources' },
|
||||
{ to: '/pricing', label: 'Pricing' },
|
||||
{ to: '/download', label: 'Download' },
|
||||
] as const
|
||||
|
||||
export function MarketingShell({
|
||||
title,
|
||||
eyebrow,
|
||||
lede,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
eyebrow: string
|
||||
lede: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { isAuthenticated } = usePlatformAuth()
|
||||
|
||||
return (
|
||||
<div className="site-shell">
|
||||
<header className="site-header">
|
||||
<Link to="/" className="brand-mark">
|
||||
<img
|
||||
src="/branding/hypertwist-3d-symbol.png"
|
||||
alt="HyperTwist"
|
||||
className="brand-mark__image"
|
||||
/>
|
||||
<span>
|
||||
<strong>{brandConfig.brandName}</strong>
|
||||
<small>{brandConfig.tagline}</small>
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="site-nav" aria-label="Primary">
|
||||
{marketingLinks.map((link) => (
|
||||
<NavLink
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className={({ isActive }) => `site-nav__link${isActive ? ' is-active' : ''}`}
|
||||
>
|
||||
{link.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="site-header__actions">
|
||||
<Link className="button button--ghost" to={isAuthenticated ? '/app' : '/login'}>
|
||||
{isAuthenticated ? 'Open dashboard' : 'Log in'}
|
||||
</Link>
|
||||
<Link className="button button--primary" to="/download">
|
||||
Get desktop app
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="page-main">
|
||||
<section className="page-hero">
|
||||
<p className="eyebrow">{eyebrow}</p>
|
||||
<h1>{title}</h1>
|
||||
<p className="page-hero__lede">{lede}</p>
|
||||
</section>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<footer className="site-footer">
|
||||
<div className="site-footer__brand">
|
||||
<strong>{brandConfig.brandName}</strong>
|
||||
<p>{brandConfig.tagline}</p>
|
||||
</div>
|
||||
<div className="site-footer__links">
|
||||
{footerLinks.map((link) => (
|
||||
<Link key={link.to} to={link.to}>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<p className="site-footer__note">
|
||||
Public distribution surfaces must continue to expose open-source notices and corresponding-source guidance when shipped builds contain MPL-covered material.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
website/src/components/routes/ProtectedRoute.tsx
Normal file
18
website/src/components/routes/ProtectedRoute.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { usePlatformAuth } from '../../auth/platform-auth'
|
||||
import { GeneralPageLoader } from '../ui/Skeletons'
|
||||
|
||||
export function ProtectedRoute() {
|
||||
const { isAuthenticated, isLoading } = usePlatformAuth()
|
||||
const location = useLocation()
|
||||
|
||||
if (isLoading) {
|
||||
return <GeneralPageLoader title="Loading operator access..." />
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to={`/login?next=${encodeURIComponent(location.pathname + location.search)}`} replace />
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
36
website/src/components/ui/Skeletons.tsx
Normal file
36
website/src/components/ui/Skeletons.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { NamedAgentSpinner } from './agent-spinners'
|
||||
|
||||
export function GeneralPageLoader({ title = 'Loading' }: { title?: string }) {
|
||||
const [isCompleting, setIsCompleting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const pulseTimer = window.setTimeout(() => {
|
||||
setIsCompleting(true)
|
||||
}, 900)
|
||||
const clearTimer = window.setTimeout(() => {
|
||||
setIsCompleting(false)
|
||||
}, 1550)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(pulseTimer)
|
||||
window.clearTimeout(clearTimer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const spinnerColor = isCompleting ? 'var(--ht-amber)' : 'var(--ht-cyan)'
|
||||
|
||||
return (
|
||||
<div className="general-page-loader-shell" role="status" aria-live="polite" aria-label={title}>
|
||||
<div className="general-page-loader-frame">
|
||||
<NamedAgentSpinner
|
||||
name="cascade"
|
||||
size={32}
|
||||
color={spinnerColor}
|
||||
className={`loading-spinner ${isCompleting ? 'is-completing' : ''}`}
|
||||
/>
|
||||
<p className="general-page-loader-text">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
website/src/components/ui/agent-spinners.tsx
Normal file
81
website/src/components/ui/agent-spinners.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useEffect, useState, type CSSProperties } from 'react'
|
||||
|
||||
const spinnerRegistry = {
|
||||
cascade: {
|
||||
frames: [
|
||||
'⠀⠀⠀⠀',
|
||||
'⠁⠀⠀⠀',
|
||||
'⠋⠀⠀⠀',
|
||||
'⠞⠁⠀⠀',
|
||||
'⡴⠋⠀⠀',
|
||||
'⣠⠞⠁⠀',
|
||||
'⢀⡴⠋⠀',
|
||||
'⠀⣠⠞⠁',
|
||||
'⠀⢀⡴⠋',
|
||||
'⠀⠀⣠⠞',
|
||||
'⠀⠀⢀⡴',
|
||||
'⠀⠀⠀⣠',
|
||||
'⠀⠀⠀⢀',
|
||||
],
|
||||
interval: 60,
|
||||
},
|
||||
orbit: {
|
||||
frames: ['⠃', '⠉', '⠘', '⠰', '⢠', '⣀', '⡄', '⠆'],
|
||||
interval: 90,
|
||||
},
|
||||
} as const
|
||||
|
||||
function useSpinnerFrame(frames: readonly string[], interval: number) {
|
||||
const [index, setIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
setIndex((current) => (current + 1) % frames.length)
|
||||
}, interval)
|
||||
return () => window.clearInterval(id)
|
||||
}, [frames, interval])
|
||||
|
||||
return frames[index] ?? ''
|
||||
}
|
||||
|
||||
export function NamedAgentSpinner({
|
||||
name,
|
||||
size = 28,
|
||||
color = 'currentColor',
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
name: keyof typeof spinnerRegistry
|
||||
size?: number
|
||||
color?: string
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}) {
|
||||
const meta = spinnerRegistry[name]
|
||||
const frame = useSpinnerFrame(meta.frames, meta.interval)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
width: size * 2.5,
|
||||
height: size * 2.5,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: size,
|
||||
color,
|
||||
lineHeight: 1,
|
||||
whiteSpace: 'pre',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
|
||||
}}
|
||||
>
|
||||
{frame}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
website/src/main.tsx
Normal file
9
website/src/main.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
207
website/src/pages/app-pages.tsx
Normal file
207
website/src/pages/app-pages.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { useMemo } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { buildAuthApiBaseUrls, createDesktopLinkToken, getAuthHealth } from '../auth/auth-api'
|
||||
import { usePlatformAuth } from '../auth/platform-auth'
|
||||
import { downloadTargets, mplSourceUrl } from '../site-config'
|
||||
import { roadmapHonestyCards } from '../site-data'
|
||||
|
||||
function Panel({
|
||||
title,
|
||||
children,
|
||||
kicker,
|
||||
}: {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
kicker?: string
|
||||
}) {
|
||||
return (
|
||||
<article className="panel">
|
||||
{kicker ? <p className="eyebrow">{kicker}</p> : null}
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardOverviewPage() {
|
||||
const { user } = usePlatformAuth()
|
||||
const healthQuery = useQuery({
|
||||
queryKey: ['auth-health'],
|
||||
queryFn: getAuthHealth,
|
||||
})
|
||||
|
||||
const desktopLinkMutation = useMutation({
|
||||
mutationFn: createDesktopLinkToken,
|
||||
})
|
||||
|
||||
const verifyUrl = useMemo(() => {
|
||||
const token = desktopLinkMutation.data?.token
|
||||
if (!token) return ''
|
||||
const [baseUrl] = buildAuthApiBaseUrls()
|
||||
if (!baseUrl) return ''
|
||||
return `${baseUrl}/api/auth/desktop-link/verify?token=${encodeURIComponent(token)}`
|
||||
}, [desktopLinkMutation.data?.token])
|
||||
|
||||
return (
|
||||
<div className="panel-grid">
|
||||
<Panel title="Account state" kicker="Live session">
|
||||
<p><strong>{user?.name}</strong></p>
|
||||
<p>{user?.email}</p>
|
||||
<p>Plan: {user?.plan}</p>
|
||||
<p>Role: {user?.role || 'operator'}</p>
|
||||
<p>Desktop downloads: {user?.canDownload ? 'enabled' : 'not yet entitled'}</p>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Auth and server health" kicker="Browser shell">
|
||||
{healthQuery.isLoading ? <p>Checking auth server health...</p> : null}
|
||||
{healthQuery.isError ? <p className="form-error">Auth health probe failed. The local fallback can still keep the dashboard usable.</p> : null}
|
||||
{healthQuery.data ? (
|
||||
<ul className="list">
|
||||
<li>Service: {healthQuery.data.service}</li>
|
||||
<li>SuperTokens configured: {healthQuery.data.supertokens.configured ? 'yes' : 'no'}</li>
|
||||
<li>SuperTokens core reachable: {healthQuery.data.supertokens.reachable ? 'yes' : 'no'}</li>
|
||||
<li>SuperTokens ready: {healthQuery.data.supertokens.ready ? 'yes' : 'no'}</li>
|
||||
<li>Fallback enabled: {healthQuery.data.fallback.enabled ? 'yes' : 'no'}</li>
|
||||
<li>Fallback active: {healthQuery.data.fallback.active ? 'yes' : 'no'}</li>
|
||||
<li>Core API version: {healthQuery.data.supertokens.apiVersion || 'unavailable'}</li>
|
||||
<li>OAuth routes: GitHub {healthQuery.data.supertokens.oauth?.github ? 'on' : 'off'}, Google {healthQuery.data.supertokens.oauth?.google ? 'on' : 'off'}</li>
|
||||
</ul>
|
||||
) : null}
|
||||
{healthQuery.data?.fallback.active ? (
|
||||
<p className="form-error">
|
||||
Shared auth core is not fully ready right now. Dashboard fallback posture remains available{healthQuery.data.fallback.reason ? ` (${healthQuery.data.fallback.reason})` : ''}.
|
||||
</p>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Desktop-link pairing" kicker="Browser to desktop handoff">
|
||||
<p>Generate a short-lived token so the local desktop runtime can verify your browser session without exposing account credentials.</p>
|
||||
<button
|
||||
className="button button--primary"
|
||||
type="button"
|
||||
onClick={() => desktopLinkMutation.mutate()}
|
||||
disabled={desktopLinkMutation.isPending}
|
||||
>
|
||||
{desktopLinkMutation.isPending ? 'Generating token...' : 'Generate desktop-link token'}
|
||||
</button>
|
||||
{desktopLinkMutation.isError ? (
|
||||
<p className="form-error">Could not generate a token. Ensure the auth server is reachable and your session is valid.</p>
|
||||
) : null}
|
||||
{desktopLinkMutation.data ? (
|
||||
<div className="code-block top-gap">
|
||||
<p>Token: <code>{desktopLinkMutation.data.token}</code></p>
|
||||
<p>Expires: {desktopLinkMutation.data.expires_at}</p>
|
||||
<p>Verify URL: <code>{verifyUrl || 'same-origin verify route'}</code></p>
|
||||
</div>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Billing and entitlement state" kicker="Release access">
|
||||
<ul className="list">
|
||||
<li>Source: {user?.billing?.source || 'session'}</li>
|
||||
<li>Access status: {user?.billing?.accessStatus || 'session-default'}</li>
|
||||
<li>Desktop download access: {user?.billing?.canDownload ? 'enabled' : 'not yet enabled'}</li>
|
||||
<li>Last billing event: {user?.billing?.lastEventType || 'none recorded'}</li>
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Product boundary" kicker="Roadmap honesty">
|
||||
<ul className="list">
|
||||
{roadmapHonestyCards.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DownloadCenterPage() {
|
||||
const { user } = usePlatformAuth()
|
||||
const canDownload = user?.canDownload === true
|
||||
|
||||
return (
|
||||
<div className="panel-grid">
|
||||
<Panel title="Release targets" kicker="Desktop distribution">
|
||||
{!canDownload ? (
|
||||
<p className="form-error">
|
||||
Your current account does not yet have desktop download entitlement.
|
||||
Complete the matching checkout or operator provisioning step, then refresh this dashboard.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="card-grid">
|
||||
{downloadTargets.map((target) => (
|
||||
<article key={target.platform} className="card card--compact">
|
||||
<h3>{target.platform}</h3>
|
||||
<p>{target.details}</p>
|
||||
{target.href && canDownload ? (
|
||||
<a className="button button--primary button--full" href={target.href}>
|
||||
Download {target.platform}
|
||||
</a>
|
||||
) : (
|
||||
<div className="button button--ghost button--full is-disabled" aria-disabled="true">
|
||||
{target.href ? 'Account not entitled yet' : 'Release URL not configured yet'}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BrowserAccessPage() {
|
||||
return (
|
||||
<div className="panel-grid">
|
||||
<Panel title="Current browser posture" kicker="Shipping lane">
|
||||
<ul className="list">
|
||||
<li>The embedded browser/CEF shell is live and owned as part of the current shipping posture.</li>
|
||||
<li>Browser runtime status, runtime-ready typing, and native operator diagnostics are already surfaced in the desktop runtime.</li>
|
||||
<li>The public browser dashboard is for operator/account/release access, not a claim of full simulator parity.</li>
|
||||
</ul>
|
||||
</Panel>
|
||||
<Panel title="Optional branches remain bounded" kicker="Not widened here">
|
||||
<ul className="list">
|
||||
<li>The optional full-browser client remains spec-only.</li>
|
||||
<li>MagicTile native renderer widening remains explicit No-Go until a real browser-host gap is proven.</li>
|
||||
<li>This dashboard intentionally stays above those boundaries instead of silently crossing them.</li>
|
||||
</ul>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AccountPage() {
|
||||
const { user, superTokensConfigured } = usePlatformAuth()
|
||||
|
||||
return (
|
||||
<div className="panel-grid">
|
||||
<Panel title="Session profile" kicker="Account details">
|
||||
<ul className="list">
|
||||
<li>Email: {user?.email}</li>
|
||||
<li>Name: {user?.name}</li>
|
||||
<li>Plan: {user?.plan}</li>
|
||||
<li>Desktop downloads: {user?.canDownload ? 'enabled' : 'not yet entitled'}</li>
|
||||
<li>Billing status: {user?.billing?.accessStatus || 'session-default'}</li>
|
||||
<li>Auth stack: {superTokensConfigured ? 'SuperTokens-backed' : 'Local fallback mode'}</li>
|
||||
</ul>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoticesPage() {
|
||||
return (
|
||||
<div className="panel-grid">
|
||||
<Panel title="Distribution notices" kicker="Legal posture">
|
||||
<p>
|
||||
Pricing, checkout, and download surfaces must expose open-source notices and corresponding-source guidance whenever shipped builds contain MPL-covered material.
|
||||
</p>
|
||||
<p>
|
||||
Current corresponding-source URL: {mplSourceUrl || 'configure VITE_MPL_SOURCE_URL before public launch'}
|
||||
</p>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
150
website/src/pages/auth-pages.tsx
Normal file
150
website/src/pages/auth-pages.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
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'
|
||||
|
||||
function AuthShell({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
title: string
|
||||
subtitle: string
|
||||
children: React.ReactNode
|
||||
footer: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="auth-shell">
|
||||
<div className="auth-card">
|
||||
<p className="eyebrow">Browser account access</p>
|
||||
<h1>{title}</h1>
|
||||
<p className="auth-card__subtitle">{subtitle}</p>
|
||||
{children}
|
||||
<div className="auth-card__footer">{footer}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useNextPath() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const next = searchParams.get('next') || '/app'
|
||||
return next.startsWith('/') ? next : '/app'
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const { login, isAuthenticated } = usePlatformAuth()
|
||||
const nextPath = useNextPath()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate(nextPath, { replace: true })
|
||||
}
|
||||
}, [isAuthenticated, navigate, nextPath])
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
const result = await login({ method: 'email', email, password })
|
||||
setIsSubmitting(false)
|
||||
if (!result.ok) {
|
||||
setError(result.error || 'Unable to sign in.')
|
||||
return
|
||||
}
|
||||
navigate(nextPath, { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
title="Log in to HyperTwist"
|
||||
subtitle="Use the same browser auth posture as FamiliarOS and ScriptoriumAI, then hand off to the desktop runtime when needed."
|
||||
footer={<p>Need access? <Link to={`/register?next=${encodeURIComponent(nextPath)}`}>Create an account</Link>.</p>}
|
||||
>
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label className="input-label" htmlFor="login-email">Email</label>
|
||||
<input id="login-email" className="input" value={email} onChange={(event) => setEmail(event.target.value)} />
|
||||
<label className="input-label" htmlFor="login-password">Password</label>
|
||||
<input id="login-password" className="input" type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<button className="button button--primary button--full" disabled={isSubmitting} type="submit">
|
||||
{isSubmitting ? 'Signing in...' : 'Log in'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="provider-row">
|
||||
{isGoogleOAuthEnabled() ? (
|
||||
<button className="button button--ghost button--full" type="button" onClick={() => void login({ method: 'google', email: '' })}>
|
||||
Continue with Google
|
||||
</button>
|
||||
) : null}
|
||||
{isGitHubOAuthEnabled() ? (
|
||||
<button className="button button--ghost button--full" type="button" onClick={() => void login({ method: 'github', email: '' })}>
|
||||
Continue with GitHub
|
||||
</button>
|
||||
) : null}
|
||||
{isOrcidOAuthEnabled() ? (
|
||||
<button className="button button--ghost button--full" type="button" onClick={() => void login({ method: 'orcid', email: '' })}>
|
||||
Continue with ORCID
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate()
|
||||
const { register, isAuthenticated } = usePlatformAuth()
|
||||
const nextPath = useNextPath()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate(nextPath, { replace: true })
|
||||
}
|
||||
}, [isAuthenticated, navigate, nextPath])
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
const result = await register({ method: 'email', email, password, name })
|
||||
setIsSubmitting(false)
|
||||
if (!result.ok) {
|
||||
setError(result.error || 'Unable to create your account.')
|
||||
return
|
||||
}
|
||||
navigate(nextPath, { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
title="Create a HyperTwist account"
|
||||
subtitle="This unlocks the browser dashboard, release posture, and desktop-link pairing for the simulator lane."
|
||||
footer={<p>Already have access? <Link to={`/login?next=${encodeURIComponent(nextPath)}`}>Log in</Link>.</p>}
|
||||
>
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label className="input-label" htmlFor="register-name">Name</label>
|
||||
<input id="register-name" className="input" value={name} onChange={(event) => setName(event.target.value)} />
|
||||
<label className="input-label" htmlFor="register-email">Email</label>
|
||||
<input id="register-email" className="input" value={email} onChange={(event) => setEmail(event.target.value)} />
|
||||
<label className="input-label" htmlFor="register-password">Password</label>
|
||||
<input id="register-password" className="input" type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<button className="button button--primary button--full" disabled={isSubmitting} type="submit">
|
||||
{isSubmitting ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
579
website/src/pages/public-pages.tsx
Normal file
579
website/src/pages/public-pages.tsx
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
import { useDeferredValue, useMemo, useState } from 'react'
|
||||
import { ArrowRight, BookOpenText, Boxes, Download, ExternalLink, Landmark, MonitorCog, Sparkles } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { MarketingShell } from '../components/layout/MarketingShell'
|
||||
import {
|
||||
brandConfig,
|
||||
downloadTargets,
|
||||
paddleReadyDescription,
|
||||
planCatalog,
|
||||
publicDocsUrl,
|
||||
releaseNotesUrl,
|
||||
} from '../site-config'
|
||||
import {
|
||||
capabilityPillars,
|
||||
changelogEntries,
|
||||
companyNarrative,
|
||||
heroMetrics,
|
||||
openSourceNotices,
|
||||
resourceCollections,
|
||||
roadmapHonestyCards,
|
||||
shippingNowCards,
|
||||
sourceAvailability,
|
||||
supportFaqs,
|
||||
} from '../site-data'
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="page-section">
|
||||
<div className="section-heading">
|
||||
<h2>{title}</h2>
|
||||
{description ? <p>{description}</p> : null}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeLanding() {
|
||||
return (
|
||||
<MarketingShell
|
||||
eyebrow="Desktop-first training. Browser-first operator access."
|
||||
title="HyperTwist turns cube practice into a real operator-grade training stack."
|
||||
lede="Recognition, replay, coaching, analytics, higher-dimensional runtime ownership, public distribution, and browser-based account access all live under one honest product story."
|
||||
>
|
||||
<section className="hero-panel">
|
||||
<div className="hero-grid">
|
||||
<div className="hero-copy">
|
||||
<p>
|
||||
HyperTwist is a native training environment for classic cube, higher-dimensional
|
||||
families, browser-assisted recognition, replay explanation, and desktop packaging.
|
||||
The public site is intentionally honest: the desktop runtime is real, the browser
|
||||
account/dashboard is real, and the optional full-browser simulator path remains spec-only.
|
||||
</p>
|
||||
<div className="button-row">
|
||||
<Link className="button button--primary" to="/download">
|
||||
Download desktop app
|
||||
</Link>
|
||||
<Link className="button button--ghost" to="/app">
|
||||
Open operator dashboard
|
||||
</Link>
|
||||
<Link className="button button--ghost" to="/pricing">
|
||||
View pricing
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-visual-card">
|
||||
<img
|
||||
src="/branding/hypertwist-3d-symbol.png"
|
||||
alt="HyperTwist symbol"
|
||||
className="hero-visual-card__image"
|
||||
/>
|
||||
<p className="hero-visual-card__caption">
|
||||
Browser account shell outside the simulator. Native Unreal runtime inside the simulator.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="metric-grid">
|
||||
{heroMetrics.map((metric) => (
|
||||
<article key={metric.label} className="metric-card">
|
||||
<strong>{metric.value}</strong>
|
||||
<span>{metric.label}</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Section
|
||||
title="What ships now"
|
||||
description="The public site only describes current product truth or explicitly marked retained/spec-only branches."
|
||||
>
|
||||
<div className="card-grid">
|
||||
{shippingNowCards.map((item) => (
|
||||
<article key={item} className="card card--compact">
|
||||
<Sparkles size={18} />
|
||||
<p>{item}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Capability pillars"
|
||||
description="The page structure borrows the FamiliarOS and ScriptoriumAI public-shell discipline, then rebrands the actual product around HyperTwist runtime authority."
|
||||
>
|
||||
<div className="card-grid">
|
||||
{capabilityPillars.map((pillar) => (
|
||||
<article key={pillar.title} className="card">
|
||||
<h3>{pillar.title}</h3>
|
||||
<p>{pillar.description}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Roadmap-honest posture"
|
||||
description="Important boundaries stay visible instead of being blurred into vague marketing claims."
|
||||
>
|
||||
<div className="split-grid">
|
||||
{roadmapHonestyCards.map((item) => (
|
||||
<article key={item} className="callout">
|
||||
<p>{item}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Delivery surfaces"
|
||||
description="Use the website for account, release, pricing, and notices. Use the desktop runtime for the core simulator."
|
||||
>
|
||||
<div className="feature-band">
|
||||
<article className="feature-band__card">
|
||||
<MonitorCog size={22} />
|
||||
<h3>Browser account and operator shell</h3>
|
||||
<p>Authenticated browser access for release posture, desktop pairing, notices, and operator state.</p>
|
||||
<Link to="/app" className="inline-link">
|
||||
Open dashboard <ArrowRight size={15} />
|
||||
</Link>
|
||||
</article>
|
||||
<article className="feature-band__card">
|
||||
<Download size={22} />
|
||||
<h3>Desktop download and package lane</h3>
|
||||
<p>Public download posture for the native Unreal build, with legal linkage already wired in.</p>
|
||||
<Link to="/download" className="inline-link">
|
||||
Open download center <ArrowRight size={15} />
|
||||
</Link>
|
||||
</article>
|
||||
<article className="feature-band__card">
|
||||
<Landmark size={22} />
|
||||
<h3>Checkout and notices discipline</h3>
|
||||
<p>Paddle-ready pricing plus public notices surfaces for any downloadable build containing MPL-covered material.</p>
|
||||
<Link to="/open-source-notices" className="inline-link">
|
||||
Review notices <ArrowRight size={15} />
|
||||
</Link>
|
||||
</article>
|
||||
</div>
|
||||
</Section>
|
||||
</MarketingShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function AboutPage() {
|
||||
return (
|
||||
<MarketingShell
|
||||
eyebrow="Why HyperTwist exists"
|
||||
title="A training stack serious enough for higher-dimensional cubing."
|
||||
lede="HyperTwist exists because cubers deserve one coherent system for physical recognition, explanation, practice, replay, analytics, and hyper puzzle ownership."
|
||||
>
|
||||
<Section title="Mission">
|
||||
<div className="card">
|
||||
<p>{companyNarrative.mission}</p>
|
||||
<p>{companyNarrative.posture}</p>
|
||||
<p>{companyNarrative.distribution}</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="What makes the product different">
|
||||
<div className="card-grid">
|
||||
<article className="card">
|
||||
<Boxes size={22} />
|
||||
<h3>It treats higher-dimensional puzzles as first-class work</h3>
|
||||
<p>120-cell and 5D runtime ownership are not hand-wavy aspirations. They are part of the current product truth.</p>
|
||||
</article>
|
||||
<article className="card">
|
||||
<BookOpenText size={22} />
|
||||
<h3>It stays roadmap-honest</h3>
|
||||
<p>Shipped, retained, and spec-only surfaces remain clearly separated so public copy matches actual authority.</p>
|
||||
</article>
|
||||
<article className="card">
|
||||
<MonitorCog size={22} />
|
||||
<h3>It separates browser shell from simulator truth</h3>
|
||||
<p>The public web surface helps operators access the product without pretending the browser already replaces the desktop runtime.</p>
|
||||
</article>
|
||||
</div>
|
||||
</Section>
|
||||
</MarketingShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResourcesPage() {
|
||||
const [query, setQuery] = useState('')
|
||||
const deferredQuery = useDeferredValue(query)
|
||||
|
||||
const filteredCollections = useMemo(() => {
|
||||
const normalized = deferredQuery.trim().toLowerCase()
|
||||
if (!normalized) return resourceCollections
|
||||
return resourceCollections
|
||||
.map((collection) => ({
|
||||
...collection,
|
||||
items: collection.items.filter((item) => item.toLowerCase().includes(normalized) || collection.title.toLowerCase().includes(normalized)),
|
||||
}))
|
||||
.filter((collection) => collection.items.length > 0)
|
||||
}, [deferredQuery])
|
||||
|
||||
return (
|
||||
<MarketingShell
|
||||
eyebrow="Public resources"
|
||||
title="Resources that explain the product without leaking operator-only internals."
|
||||
lede="This surface is shaped after the FamiliarOS and ScriptoriumAI public resource pages, but constrained to safe HyperTwist product-facing material."
|
||||
>
|
||||
<Section title="Resource finder" description="Search the public resource categories that matter to operators and buyers.">
|
||||
<label className="input-label" htmlFor="resource-query">Filter resources</label>
|
||||
<input
|
||||
id="resource-query"
|
||||
className="input"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search training, rollout, notices..."
|
||||
/>
|
||||
<div className="card-grid top-gap">
|
||||
{filteredCollections.map((collection) => (
|
||||
<article key={collection.title} className="card">
|
||||
<h3>{collection.title}</h3>
|
||||
<ul className="list">
|
||||
{collection.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Direct routes">
|
||||
<div className="feature-band">
|
||||
<Link to="/docs" className="feature-band__card feature-band__card--link">
|
||||
<BookOpenText size={22} />
|
||||
<h3>Docs landing</h3>
|
||||
<p>Product-facing documentation, boundaries, and rollout guidance.</p>
|
||||
</Link>
|
||||
<Link to="/support" className="feature-band__card feature-band__card--link">
|
||||
<ExternalLink size={22} />
|
||||
<h3>Support</h3>
|
||||
<p>Contact, rollout questions, and account/download help.</p>
|
||||
</Link>
|
||||
<Link to="/changelog" className="feature-band__card feature-band__card--link">
|
||||
<Sparkles size={22} />
|
||||
<h3>Release notes</h3>
|
||||
<p>Recent public-facing packets and posture updates.</p>
|
||||
</Link>
|
||||
</div>
|
||||
</Section>
|
||||
</MarketingShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function DocsPage() {
|
||||
return (
|
||||
<MarketingShell
|
||||
eyebrow="Documentation posture"
|
||||
title="HyperTwist documentation stays capability-accurate."
|
||||
lede="The public docs surface points users to product-safe truth: feature registry discipline, roadmap honesty, release notes, and distribution/legal guidance."
|
||||
>
|
||||
<Section title="Public documentation lanes">
|
||||
<div className="card-grid">
|
||||
<article className="card">
|
||||
<h3>Feature truth</h3>
|
||||
<p>Use documentation derived from the feature registry so shipped capability, retained capability, and spec-only branches are not mixed together.</p>
|
||||
</article>
|
||||
<article className="card">
|
||||
<h3>Roadmap truth</h3>
|
||||
<p>Roadmap-facing copy should say the embedded browser shell is live, while the optional full-browser client remains separate and frozen as spec-only.</p>
|
||||
</article>
|
||||
<article className="card">
|
||||
<h3>Distribution truth</h3>
|
||||
<p>Pricing, checkout, and download docs must stay linked to open-source notices whenever shipped builds contain MPL-covered material.</p>
|
||||
</article>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{publicDocsUrl ? (
|
||||
<Section title="External docs portal">
|
||||
<a className="button button--primary" href={publicDocsUrl} target="_blank" rel="noreferrer">
|
||||
Open public docs portal
|
||||
</a>
|
||||
</Section>
|
||||
) : null}
|
||||
</MarketingShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function SupportPage() {
|
||||
return (
|
||||
<MarketingShell
|
||||
eyebrow="Operator help"
|
||||
title="Support for rollout, downloads, pricing, and browser-to-desktop access."
|
||||
lede="Support is focused on helping operators understand what the browser shell does, what the desktop build does, and how the two connect."
|
||||
>
|
||||
<Section title="Contact">
|
||||
<article className="card">
|
||||
<p>
|
||||
Email <a href={brandConfig.contact.emailHref}>{brandConfig.contact.email}</a> for account activation,
|
||||
release enablement, pricing configuration, or deployment help.
|
||||
</p>
|
||||
</article>
|
||||
</Section>
|
||||
|
||||
<Section title="Frequently asked questions">
|
||||
<div className="card-grid">
|
||||
{supportFaqs.map((faq) => (
|
||||
<article className="card" key={faq.question}>
|
||||
<h3>{faq.question}</h3>
|
||||
<p>{faq.answer}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</MarketingShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChangelogPage() {
|
||||
return (
|
||||
<MarketingShell
|
||||
eyebrow="Release notes"
|
||||
title="Recent public-facing HyperTwist changes."
|
||||
lede="This page focuses on user-visible posture changes: browser operator access, package hardening, diagnostics, and distribution readiness."
|
||||
>
|
||||
<Section title="Latest changes">
|
||||
<div className="timeline">
|
||||
{changelogEntries.map((entry) => (
|
||||
<article key={`${entry.date}-${entry.title}`} className="timeline-entry">
|
||||
<p className="timeline-entry__date">{entry.date}</p>
|
||||
<h3>{entry.title}</h3>
|
||||
<p>{entry.details}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
{releaseNotesUrl ? (
|
||||
<Section title="External release notes">
|
||||
<a className="button button--ghost" href={releaseNotesUrl} target="_blank" rel="noreferrer">
|
||||
Open release feed
|
||||
</a>
|
||||
</Section>
|
||||
) : null}
|
||||
</MarketingShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function PricingPage() {
|
||||
return (
|
||||
<MarketingShell
|
||||
eyebrow="Paddle-ready plans"
|
||||
title="Pricing that matches the actual delivery model."
|
||||
lede="The browser shell handles account, release, and billing access. The simulator remains desktop-first. Prices and checkouts can be switched live through the same Paddle-ready structure used across the broader product family."
|
||||
>
|
||||
<Section title="Plan lineup" description={paddleReadyDescription}>
|
||||
<div className="card-grid card-grid--pricing">
|
||||
{planCatalog.map((plan) => (
|
||||
<article key={plan.key} className="card card--pricing">
|
||||
<p className="status-pill">{plan.name}</p>
|
||||
<h3>{plan.price}</h3>
|
||||
<p>{plan.notes}</p>
|
||||
<ul className="list">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature}>{feature}</li>
|
||||
))}
|
||||
</ul>
|
||||
<a className="button button--primary button--full" href={plan.ctaHref}>
|
||||
{plan.ctaLabel}
|
||||
</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Important launch note">
|
||||
<article className="callout">
|
||||
<p>
|
||||
Public pricing, checkout, and download pages are distribution surfaces. Before external launch,
|
||||
keep their legal footer and open-source notices link live and ensure the corresponding-source URL is configured for any downloadable build containing MPL-covered material.
|
||||
</p>
|
||||
</article>
|
||||
</Section>
|
||||
</MarketingShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function DownloadPage() {
|
||||
return (
|
||||
<MarketingShell
|
||||
eyebrow="Desktop distribution"
|
||||
title="Download the desktop build and pair it with your browser account."
|
||||
lede="The website provides account, release, and legal surfaces. The actual simulator ships through the desktop lane, with package validation and release discipline carried over from the HyperTwist roadmap."
|
||||
>
|
||||
<Section title="Available targets">
|
||||
<div className="card-grid">
|
||||
{downloadTargets.map((target) => (
|
||||
<article key={target.platform} className="card">
|
||||
<h3>{target.platform}</h3>
|
||||
<p className="status-pill">{target.subtitle}</p>
|
||||
<p>{target.details}</p>
|
||||
{target.href ? (
|
||||
<Link className="button button--primary button--full" to="/app/downloads">
|
||||
Sign in for {target.platform} access
|
||||
</Link>
|
||||
) : (
|
||||
<div className="button button--ghost button--full is-disabled" aria-disabled="true">
|
||||
Release URL not configured yet
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Why this page does not expose raw download URLs">
|
||||
<article className="callout">
|
||||
<p>
|
||||
HyperTwist treats desktop distribution as an account-gated release surface.
|
||||
Public pages can describe supported targets and release posture, but the actual
|
||||
download links live behind the protected dashboard where plan and entitlement
|
||||
state are resolved.
|
||||
</p>
|
||||
<Link className="button button--ghost" to="/login">
|
||||
Sign in to check access
|
||||
</Link>
|
||||
</article>
|
||||
</Section>
|
||||
|
||||
<Section title="Pair the desktop app with your browser account">
|
||||
<article className="callout">
|
||||
<p>
|
||||
After sign-in, open the operator dashboard to generate a desktop-link token.
|
||||
That token is designed to hand browser identity and plan posture over to the local desktop app without exposing your password.
|
||||
</p>
|
||||
<Link className="button button--ghost" to="/app">
|
||||
Open dashboard
|
||||
</Link>
|
||||
</article>
|
||||
</Section>
|
||||
</MarketingShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function OpenSourceNoticesPage() {
|
||||
return (
|
||||
<MarketingShell
|
||||
eyebrow="Legal and notices"
|
||||
title="Open-source notices for public distribution surfaces."
|
||||
lede="HyperTwist pricing, checkout, release, and download pages must make legal and corresponding-source guidance visible whenever shipped builds include MPL-covered material."
|
||||
>
|
||||
<Section title="Key components">
|
||||
<div className="card-grid">
|
||||
{openSourceNotices.map((item) => (
|
||||
<article className="card" key={item.component}>
|
||||
<h3>{item.component}</h3>
|
||||
<p className="status-pill">{item.license}</p>
|
||||
<p>{item.whyItMatters}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Corresponding source">
|
||||
<div className="card">
|
||||
<p>
|
||||
MPL-covered shipped builds need a stable corresponding-source location for the exact distributed material.
|
||||
</p>
|
||||
<ul className="list">
|
||||
<li>
|
||||
Public corresponding-source URL:{' '}
|
||||
{sourceAvailability.mplSourceUrl ? (
|
||||
<a href={sourceAvailability.mplSourceUrl} target="_blank" rel="noreferrer">{sourceAvailability.mplSourceUrl}</a>
|
||||
) : (
|
||||
'configure VITE_MPL_SOURCE_URL before public launch'
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
Public repository / notices reference:{' '}
|
||||
{sourceAvailability.openSourceRepoUrl ? (
|
||||
<a href={sourceAvailability.openSourceRepoUrl} target="_blank" rel="noreferrer">{sourceAvailability.openSourceRepoUrl}</a>
|
||||
) : (
|
||||
'configure VITE_OPEN_SOURCE_REPO_URL before public launch'
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Official MPL 2.0 license text:{' '}
|
||||
<a href="https://www.mozilla.org/en-US/MPL/2.0/" target="_blank" rel="noreferrer">
|
||||
https://www.mozilla.org/en-US/MPL/2.0/
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
</MarketingShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrivacyPage() {
|
||||
return (
|
||||
<MarketingShell
|
||||
eyebrow="Privacy"
|
||||
title="Privacy posture"
|
||||
lede="HyperTwist keeps the browser shell narrow and the simulator desktop-first. Privacy descriptions must reflect that separation clearly."
|
||||
>
|
||||
<Section title="Core points">
|
||||
<article className="card">
|
||||
<ul className="list">
|
||||
<li>The public website stores account/session data needed for authentication, plan access, and desktop-link issuance.</li>
|
||||
<li>The browser shell does not claim ownership over the full simulator runtime state unless a future browser-client packet is explicitly opened.</li>
|
||||
<li>Support, billing, and release operations should collect only the data required to deliver digital access and maintain legal compliance.</li>
|
||||
</ul>
|
||||
</article>
|
||||
</Section>
|
||||
</MarketingShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function TermsPage() {
|
||||
return (
|
||||
<MarketingShell
|
||||
eyebrow="Terms"
|
||||
title="Terms of access"
|
||||
lede="HyperTwist access is digital-first and plan-gated. Terms should match the actual delivery and account model."
|
||||
>
|
||||
<Section title="Service posture">
|
||||
<article className="card">
|
||||
<ul className="list">
|
||||
<li>Browser access covers public pages, account, release, download, and operator/dashboard surfaces.</li>
|
||||
<li>The simulator itself is delivered through the desktop lane unless a later browser-client branch is explicitly opened.</li>
|
||||
<li>Downloaded builds and their public distribution pages remain subject to open-source notice and corresponding-source disclosure rules where applicable.</li>
|
||||
</ul>
|
||||
</article>
|
||||
</Section>
|
||||
</MarketingShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function ShippingPaymentPage() {
|
||||
return (
|
||||
<MarketingShell
|
||||
eyebrow="Shipping & payment"
|
||||
title="Digital delivery only"
|
||||
lede="HyperTwist is not a physical-goods storefront. Delivery happens through authenticated account access, release pages, and desktop downloads."
|
||||
>
|
||||
<Section title="Delivery model">
|
||||
<article className="card">
|
||||
<p>{paddleReadyDescription}</p>
|
||||
<ul className="list">
|
||||
<li>No physical goods ship through this site.</li>
|
||||
<li>Pricing and checkout are structured for Paddle-backed digital plans.</li>
|
||||
<li>Desktop downloads must remain paired with public notices and legal links when required by shipped-code obligations.</li>
|
||||
</ul>
|
||||
</article>
|
||||
</Section>
|
||||
</MarketingShell>
|
||||
)
|
||||
}
|
||||
36
website/src/router/AppRouteTree.tsx
Normal file
36
website/src/router/AppRouteTree.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AppShell } from '../components/layout/AppShell'
|
||||
import { ProtectedRoute } from '../components/routes/ProtectedRoute'
|
||||
import { renderPublicRoutes } from './PublicRoutes'
|
||||
import { Suspense, lazy } from 'react'
|
||||
import { GeneralPageLoader } from '../components/ui/Skeletons'
|
||||
|
||||
const DashboardOverviewPage = lazy(() => import('../pages/app-pages').then((m) => ({ default: m.DashboardOverviewPage })))
|
||||
const DownloadCenterPage = lazy(() => import('../pages/app-pages').then((m) => ({ default: m.DownloadCenterPage })))
|
||||
const BrowserAccessPage = lazy(() => import('../pages/app-pages').then((m) => ({ default: m.BrowserAccessPage })))
|
||||
const AccountPage = lazy(() => import('../pages/app-pages').then((m) => ({ default: m.AccountPage })))
|
||||
const NoticesPage = lazy(() => import('../pages/app-pages').then((m) => ({ default: m.NoticesPage })))
|
||||
|
||||
function withAppLoader(title: string, element: React.ReactNode) {
|
||||
return <Suspense fallback={<GeneralPageLoader title={title} />}>{element}</Suspense>
|
||||
}
|
||||
|
||||
export function AppRouteTree() {
|
||||
return (
|
||||
<Routes>
|
||||
{renderPublicRoutes()}
|
||||
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/app" element={<AppShell />}>
|
||||
<Route index element={withAppLoader('Loading dashboard...', <DashboardOverviewPage />)} />
|
||||
<Route path="downloads" element={withAppLoader('Loading downloads...', <DownloadCenterPage />)} />
|
||||
<Route path="browser-access" element={withAppLoader('Loading browser access...', <BrowserAccessPage />)} />
|
||||
<Route path="account" element={withAppLoader('Loading account...', <AccountPage />)} />
|
||||
<Route path="notices" element={withAppLoader('Loading notices...', <NoticesPage />)} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
43
website/src/router/PublicRoutes.tsx
Normal file
43
website/src/router/PublicRoutes.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { Suspense, lazy, type ReactNode } from 'react'
|
||||
import { Route } from 'react-router-dom'
|
||||
import { GeneralPageLoader } from '../components/ui/Skeletons'
|
||||
|
||||
const HomeLanding = lazy(() => import('../pages/public-pages').then((m) => ({ default: m.HomeLanding })))
|
||||
const AboutPage = lazy(() => import('../pages/public-pages').then((m) => ({ default: m.AboutPage })))
|
||||
const ResourcesPage = lazy(() => import('../pages/public-pages').then((m) => ({ default: m.ResourcesPage })))
|
||||
const DocsPage = lazy(() => import('../pages/public-pages').then((m) => ({ default: m.DocsPage })))
|
||||
const SupportPage = lazy(() => import('../pages/public-pages').then((m) => ({ default: m.SupportPage })))
|
||||
const ChangelogPage = lazy(() => import('../pages/public-pages').then((m) => ({ default: m.ChangelogPage })))
|
||||
const PricingPage = lazy(() => import('../pages/public-pages').then((m) => ({ default: m.PricingPage })))
|
||||
const DownloadPage = lazy(() => import('../pages/public-pages').then((m) => ({ default: m.DownloadPage })))
|
||||
const OpenSourceNoticesPage = lazy(() => import('../pages/public-pages').then((m) => ({ default: m.OpenSourceNoticesPage })))
|
||||
const PrivacyPage = lazy(() => import('../pages/public-pages').then((m) => ({ default: m.PrivacyPage })))
|
||||
const TermsPage = lazy(() => import('../pages/public-pages').then((m) => ({ default: m.TermsPage })))
|
||||
const ShippingPaymentPage = lazy(() => import('../pages/public-pages').then((m) => ({ default: m.ShippingPaymentPage })))
|
||||
const LoginPage = lazy(() => import('../pages/auth-pages').then((m) => ({ default: m.LoginPage })))
|
||||
const RegisterPage = lazy(() => import('../pages/auth-pages').then((m) => ({ default: m.RegisterPage })))
|
||||
|
||||
function withGeneralLoader(title: string, element: ReactNode) {
|
||||
return <Suspense fallback={<GeneralPageLoader title={title} />}>{element}</Suspense>
|
||||
}
|
||||
|
||||
export function renderPublicRoutes() {
|
||||
return (
|
||||
<>
|
||||
<Route path="/" element={withGeneralLoader('Loading HyperTwist...', <HomeLanding />)} />
|
||||
<Route path="/about" element={withGeneralLoader('About HyperTwist', <AboutPage />)} />
|
||||
<Route path="/resources" element={withGeneralLoader('Resources', <ResourcesPage />)} />
|
||||
<Route path="/docs" element={withGeneralLoader('Documentation', <DocsPage />)} />
|
||||
<Route path="/support" element={withGeneralLoader('Support', <SupportPage />)} />
|
||||
<Route path="/changelog" element={withGeneralLoader('Release notes', <ChangelogPage />)} />
|
||||
<Route path="/pricing" element={withGeneralLoader('Pricing', <PricingPage />)} />
|
||||
<Route path="/download" element={withGeneralLoader('Download center', <DownloadPage />)} />
|
||||
<Route path="/open-source-notices" element={withGeneralLoader('Open source notices', <OpenSourceNoticesPage />)} />
|
||||
<Route path="/privacy" element={withGeneralLoader('Privacy', <PrivacyPage />)} />
|
||||
<Route path="/terms" element={withGeneralLoader('Terms', <TermsPage />)} />
|
||||
<Route path="/shipping-payment" element={withGeneralLoader('Shipping & payment', <ShippingPaymentPage />)} />
|
||||
<Route path="/login" element={withGeneralLoader('Log in', <LoginPage />)} />
|
||||
<Route path="/register" element={withGeneralLoader('Create account', <RegisterPage />)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
4
website/src/router/router-future.ts
Normal file
4
website/src/router/router-future.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export const ROUTER_FUTURE_FLAGS = {
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
} as const
|
||||
93
website/src/site-config.ts
Normal file
93
website/src/site-config.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
const env = import.meta.env as Record<string, string | undefined>
|
||||
|
||||
function readTrimmedEnv(name: string, fallback = '') {
|
||||
return String(env[name] || fallback).trim()
|
||||
}
|
||||
|
||||
export const brandConfig = {
|
||||
brandName: 'HyperTwist',
|
||||
legalName: 'HyperTwist',
|
||||
domain: 'hypertwist.app',
|
||||
tagline: 'Native cube and hypercube training, from first solve to 120-cell.',
|
||||
contact: {
|
||||
email: readTrimmedEnv('VITE_SUPPORT_EMAIL', 'hello@hypertwist.app'),
|
||||
emailHref: `mailto:${readTrimmedEnv('VITE_SUPPORT_EMAIL', 'hello@hypertwist.app')}`,
|
||||
},
|
||||
payment: {
|
||||
provider: 'Paddle',
|
||||
},
|
||||
}
|
||||
|
||||
export const planCatalog = [
|
||||
{
|
||||
key: 'explorer',
|
||||
name: 'Explorer',
|
||||
price: 'Free',
|
||||
ctaLabel: 'Start in the browser',
|
||||
ctaHref: '/register',
|
||||
notes: 'Preview the browser account shell, operator dashboard, release notes, and desktop onboarding posture.',
|
||||
features: [
|
||||
'Browser account access',
|
||||
'Release notes and resource center',
|
||||
'Operator dashboard preview',
|
||||
'Open-source notices and distribution transparency',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'operator',
|
||||
name: 'Operator',
|
||||
price: readTrimmedEnv('VITE_PLAN_PRICE_OPERATOR', 'Launch pricing via Paddle'),
|
||||
ctaLabel: readTrimmedEnv('VITE_PADDLE_CHECKOUT_URL_OPERATOR') ? 'Open Paddle checkout' : 'Request operator access',
|
||||
ctaHref: readTrimmedEnv('VITE_PADDLE_CHECKOUT_URL_OPERATOR', '/pricing'),
|
||||
notes: 'Desktop-first recognition, replay, training, and higher-dimensional runtime ownership for active training operators.',
|
||||
features: [
|
||||
'Desktop download access',
|
||||
'Desktop-link token handoff',
|
||||
'Recognition, replay, and coaching ownership',
|
||||
'Classic-cube package and validation lane access',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'studio',
|
||||
name: 'Studio',
|
||||
price: readTrimmedEnv('VITE_PLAN_PRICE_STUDIO', 'Contact for launch readiness'),
|
||||
ctaLabel: readTrimmedEnv('VITE_PADDLE_CHECKOUT_URL_STUDIO') ? 'Open Paddle checkout' : 'Talk to HyperTwist',
|
||||
ctaHref: readTrimmedEnv('VITE_PADDLE_CHECKOUT_URL_STUDIO', '/support'),
|
||||
notes: 'Higher-dimensional families, operator deployment support, and packaging/validation coordination for production lanes.',
|
||||
features: [
|
||||
'Magic120Cell and 5D operator posture',
|
||||
'Release planning and deployment coordination',
|
||||
'Desktop distribution and legal-notice readiness',
|
||||
'Custom support for rollout and training programs',
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
export const downloadTargets = [
|
||||
{
|
||||
platform: 'Windows',
|
||||
subtitle: 'Primary shipping lane',
|
||||
href: readTrimmedEnv('VITE_WINDOWS_DOWNLOAD_URL'),
|
||||
details: 'Current packaged validation is strongest on the Windows Unreal lane.',
|
||||
},
|
||||
{
|
||||
platform: 'macOS',
|
||||
subtitle: 'Planned distribution surface',
|
||||
href: readTrimmedEnv('VITE_MAC_DOWNLOAD_URL'),
|
||||
details: 'List a signed desktop build here when the package lane is opened.',
|
||||
},
|
||||
{
|
||||
platform: 'Linux',
|
||||
subtitle: 'Operator-targeted later lane',
|
||||
href: readTrimmedEnv('VITE_LINUX_DOWNLOAD_URL'),
|
||||
details: 'Use for future package publication after the bounded release lane is widened.',
|
||||
},
|
||||
] as const
|
||||
|
||||
export const publicDocsUrl = readTrimmedEnv('VITE_PUBLIC_DOCS_URL')
|
||||
export const releaseNotesUrl = readTrimmedEnv('VITE_RELEASE_NOTES_URL')
|
||||
export const mplSourceUrl = readTrimmedEnv('VITE_MPL_SOURCE_URL')
|
||||
export const openSourceRepoUrl = readTrimmedEnv('VITE_OPEN_SOURCE_REPO_URL')
|
||||
|
||||
export const paddleReadyDescription =
|
||||
'HyperTwist is a desktop-first training environment for classic cube, recognition-assisted reconstruction, replay explanation, coaching, higher-dimensional runtime ownership, and browser-based operator access. Delivery is digital-only through authenticated account access and desktop downloads. Pricing and checkout are wired for Paddle, while public legal surfaces stay responsible for open-source notices and corresponding-source disclosure when shipped builds contain MPL-covered components.'
|
||||
153
website/src/site-data.ts
Normal file
153
website/src/site-data.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { brandConfig, downloadTargets, mplSourceUrl, openSourceRepoUrl, publicDocsUrl, releaseNotesUrl } from './site-config'
|
||||
|
||||
export const heroMetrics = [
|
||||
{ label: 'Current runtime center', value: 'Native Unreal' },
|
||||
{ label: 'Higher-dimensional ownership', value: '120-cell + 5D' },
|
||||
{ label: 'Browser posture', value: 'Embedded shell live' },
|
||||
{ label: 'Package gate', value: 'Phase 10B/10C closed' },
|
||||
] as const
|
||||
|
||||
export const capabilityPillars = [
|
||||
{
|
||||
title: 'Recognition to reconstruction',
|
||||
description: 'Classic-cube intake, correction closure, browser-assisted recognition, and solve guidance are already first-party owned surfaces.',
|
||||
},
|
||||
{
|
||||
title: 'Replay, coaching, and analytics',
|
||||
description: 'Replay capture, explanation, leaderboard persistence, training analytics, and operator diagnostics are part of the current shipping lane.',
|
||||
},
|
||||
{
|
||||
title: 'Higher-dimensional seriousness',
|
||||
description: 'Hyper puzzle catalog, replay verification, 120-cell and 5D runtime ownership, and non-Euclidean tiling posture are all represented honestly.',
|
||||
},
|
||||
{
|
||||
title: 'Desktop-first distribution',
|
||||
description: 'Browser account access supports the operator, while the real simulator and package-validation story remain desktop-first and Unreal-backed.',
|
||||
},
|
||||
] as const
|
||||
|
||||
export const shippingNowCards = [
|
||||
'Native Unreal training runtime, coaching cockpit, and generated-mode launch',
|
||||
'Classic-cube timing, drill flows, local leaderboard persistence, and replay recording',
|
||||
'Browser-assisted recognition shell with correction closure and recommendation readout',
|
||||
'Embedded browser runtime with live operator/runtime diagnostics inside Unreal',
|
||||
'MagicTile browser host bridge plus bounded native behavior proof',
|
||||
'Higher-dimensional 120-cell and 5D runtime-state, projection, and persistence ownership',
|
||||
] as const
|
||||
|
||||
export const roadmapHonestyCards = [
|
||||
'The embedded browser shell is shipping. The optional full-browser client remains spec-only and is not presented as a live simulator.',
|
||||
'Native renderer widening for MagicTile stays explicitly No-Go until a real browser-host gap is evidenced.',
|
||||
'Pricing and checkout are Paddle-ready, but public launch still requires production checkout URLs and corresponding-source publication for MPL-covered shipped builds.',
|
||||
'Desktop distribution is the primary product lane. Browser access is for account, operator, release, and support surfaces unless a later browser-client packet is deliberately reopened.',
|
||||
] as const
|
||||
|
||||
export const resourceCollections = [
|
||||
{
|
||||
title: 'Product truth',
|
||||
items: [
|
||||
'Feature registry-backed descriptions only',
|
||||
'Roadmap-honest separation between shipped and retained capability',
|
||||
'Release notes that surface what actually landed',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Operator rollout',
|
||||
items: [
|
||||
'Desktop download instructions and release channels',
|
||||
'Desktop-link handshake for browser-to-desktop sign-in',
|
||||
'Legal/notices linkage for public distribution surfaces',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Training depth',
|
||||
items: [
|
||||
'Classic-cube recognition and correction workflows',
|
||||
'Replay, coaching, analytics, and package validation posture',
|
||||
'Higher-dimensional puzzle-family references and browser-host boundaries',
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
export const changelogEntries = [
|
||||
{
|
||||
date: 'June 22, 2026',
|
||||
title: 'Public HyperTwist website, auth shell, and distribution surfaces landed',
|
||||
details: 'A dedicated hypertwist.app app now exists for public pages, browser account access, download posture, public notices, and Paddle-ready pricing.',
|
||||
},
|
||||
{
|
||||
date: 'June 19, 2026',
|
||||
title: 'Embedded browser runtime hardened for operator diagnostics',
|
||||
details: 'Runtime-ready, runtime-status, and native training/dashboard diagnostics were typed and surfaced as first-party operator status.',
|
||||
},
|
||||
{
|
||||
date: 'June 19, 2026',
|
||||
title: 'MagicTile native behavior proof and renderer gate frozen',
|
||||
details: 'The native behavior proof seam landed and the renderer-port decision remained explicit No-Go without new browser-host failure evidence.',
|
||||
},
|
||||
{
|
||||
date: 'June 18, 2026',
|
||||
title: 'Classic-cube package/integration validation refreshed',
|
||||
details: 'Phase 10B and 10C were revalidated with packaged smoke coverage, stricter package helper enforcement, and maintained archive proof.',
|
||||
},
|
||||
] as const
|
||||
|
||||
export const openSourceNotices = [
|
||||
{
|
||||
component: 'cubing/cubing.js',
|
||||
license: 'MPL-2.0',
|
||||
whyItMatters: 'HyperTwist relies on an accepted MPL-covered route for current live cubing functionality.',
|
||||
},
|
||||
{
|
||||
component: 'coqui-ai/TTS package code',
|
||||
license: 'MPL-2.0',
|
||||
whyItMatters: 'If shipped desktop builds contain this code, public pricing/download pages and the installed app must expose notices and source availability.',
|
||||
},
|
||||
{
|
||||
component: 'Retained permissive browser/runtime sidecars',
|
||||
license: 'MIT / permissive family',
|
||||
whyItMatters: 'These support the public site and embedded browser surfaces, but the public legal surface still needs to stay easy to find and stable.',
|
||||
},
|
||||
] as const
|
||||
|
||||
export const sourceAvailability = {
|
||||
mplSourceUrl,
|
||||
openSourceRepoUrl,
|
||||
docsUrl: publicDocsUrl,
|
||||
releaseNotesUrl,
|
||||
}
|
||||
|
||||
export const footerLinks = [
|
||||
{ to: '/about', label: 'About' },
|
||||
{ to: '/resources', label: 'Resources' },
|
||||
{ to: '/pricing', label: 'Pricing' },
|
||||
{ to: '/download', label: 'Download' },
|
||||
{ to: '/open-source-notices', label: 'Open Source Notices' },
|
||||
{ to: '/privacy', label: 'Privacy' },
|
||||
{ to: '/terms', label: 'Terms' },
|
||||
{ to: '/shipping-payment', label: 'Shipping & Payment' },
|
||||
] as const
|
||||
|
||||
export const supportFaqs = [
|
||||
{
|
||||
question: 'Is the simulator fully in the browser?',
|
||||
answer: 'No. The current shipping lane is desktop-first and Unreal-backed. The public website offers account, operator, support, and download access, while the optional full-browser simulator path remains spec-only.',
|
||||
},
|
||||
{
|
||||
question: 'Can I download a build immediately after sign-in?',
|
||||
answer: 'Yes, once a release URL is configured for your plan. The dashboard also exposes a desktop-link token so the browser account can pair with the desktop app safely.',
|
||||
},
|
||||
{
|
||||
question: 'Why is there an open-source notices page on pricing and download surfaces?',
|
||||
answer: 'Because HyperTwist already carries explicit doctrine requiring public legal and corresponding-source linkage whenever a downloadable shipped build contains MPL-covered material.',
|
||||
},
|
||||
] as const
|
||||
|
||||
export const companyNarrative = {
|
||||
mission: 'HyperTwist closes the gap between physical cubing practice, deep replay analysis, operator-grade coaching, and serious higher-dimensional puzzle study.',
|
||||
posture:
|
||||
'The product is intentionally honest about what already ships, what is retained and planned, and what remains optional or spec-only. That honesty is part of the platform quality bar.',
|
||||
distribution:
|
||||
'The website exists to support public positioning, authenticated operator access, pricing, notices, and desktop distribution without pretending the browser itself already replaces the real desktop runtime.',
|
||||
contactEmail: brandConfig.contact.email,
|
||||
}
|
||||
671
website/src/styles/global.css
Normal file
671
website/src/styles/global.css
Normal file
|
|
@ -0,0 +1,671 @@
|
|||
:root {
|
||||
--ht-bg: #070b18;
|
||||
--ht-bg-2: #0d1630;
|
||||
--ht-surface: rgba(13, 20, 41, 0.82);
|
||||
--ht-surface-strong: rgba(14, 23, 48, 0.94);
|
||||
--ht-border: rgba(113, 213, 255, 0.18);
|
||||
--ht-border-strong: rgba(247, 178, 103, 0.34);
|
||||
--ht-text: #eef4ff;
|
||||
--ht-muted: #b9c8e0;
|
||||
--ht-cyan: #54cbff;
|
||||
--ht-cyan-soft: rgba(84, 203, 255, 0.16);
|
||||
--ht-amber: #f7b267;
|
||||
--ht-amber-soft: rgba(247, 178, 103, 0.12);
|
||||
--ht-success: #9effc7;
|
||||
--ht-grid: rgba(148, 201, 255, 0.06);
|
||||
--font-display: "Space Grotesk", "IBM Plex Sans", sans-serif;
|
||||
--font-body: "IBM Plex Sans", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html[data-theme="light"] {
|
||||
--ht-bg: #f4f8ff;
|
||||
--ht-bg-2: #dbeaff;
|
||||
--ht-surface: rgba(255, 255, 255, 0.85);
|
||||
--ht-surface-strong: rgba(255, 255, 255, 0.96);
|
||||
--ht-border: rgba(9, 54, 122, 0.14);
|
||||
--ht-border-strong: rgba(194, 104, 15, 0.3);
|
||||
--ht-text: #081426;
|
||||
--ht-muted: #4f6686;
|
||||
--ht-cyan: #0d80b8;
|
||||
--ht-cyan-soft: rgba(13, 128, 184, 0.12);
|
||||
--ht-amber: #c8680f;
|
||||
--ht-amber-soft: rgba(200, 104, 15, 0.12);
|
||||
--ht-success: #0f8b56;
|
||||
--ht-grid: rgba(35, 90, 167, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
color: var(--ht-text);
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(84, 203, 255, 0.18), transparent 30%),
|
||||
radial-gradient(circle at 85% 15%, rgba(247, 178, 103, 0.18), transparent 26%),
|
||||
linear-gradient(180deg, var(--ht-bg), var(--ht-bg-2));
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(var(--ht-grid) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--ht-grid) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
mask-image: linear-gradient(180deg, rgba(255,255,255,0.28), transparent 68%);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.site-shell,
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.site-header,
|
||||
.page-main,
|
||||
.site-footer,
|
||||
.app-topbar,
|
||||
.app-main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
padding: 1.25rem clamp(1rem, 3vw, 2.25rem);
|
||||
border-bottom: 1px solid var(--ht-border);
|
||||
backdrop-filter: blur(18px);
|
||||
background: rgba(8, 12, 22, 0.42);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-mark__image,
|
||||
.app-sidebar__brand-image {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 0 18px rgba(84, 203, 255, 0.2));
|
||||
}
|
||||
|
||||
.brand-mark strong,
|
||||
.app-sidebar__brand strong {
|
||||
display: block;
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.brand-mark small,
|
||||
.app-sidebar__brand p,
|
||||
.site-footer__brand p,
|
||||
.app-topbar__user span {
|
||||
display: block;
|
||||
color: var(--ht-muted);
|
||||
}
|
||||
|
||||
.site-nav,
|
||||
.site-header__actions,
|
||||
.button-row,
|
||||
.feature-band,
|
||||
.metric-grid,
|
||||
.card-grid,
|
||||
.split-grid,
|
||||
.panel-grid,
|
||||
.app-nav,
|
||||
.site-footer__links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.site-nav__link,
|
||||
.app-nav__link {
|
||||
padding: 0.7rem 0.95rem;
|
||||
border-radius: 999px;
|
||||
color: var(--ht-muted);
|
||||
transition: 160ms ease;
|
||||
}
|
||||
|
||||
.site-nav__link:hover,
|
||||
.site-nav__link.is-active,
|
||||
.app-nav__link:hover,
|
||||
.app-nav__link.is-active {
|
||||
color: var(--ht-text);
|
||||
background: var(--ht-cyan-soft);
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 2.8rem;
|
||||
padding: 0.8rem 1.1rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
background: linear-gradient(120deg, var(--ht-amber), #ffc788);
|
||||
color: #09111d;
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: var(--ht-border);
|
||||
color: var(--ht-text);
|
||||
}
|
||||
|
||||
.button--full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button.is-disabled,
|
||||
.button:disabled {
|
||||
opacity: 0.62;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.page-main {
|
||||
width: min(1180px, calc(100vw - 2rem));
|
||||
margin: 0 auto;
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
padding: clamp(2.25rem, 6vw, 5rem) 0 1.5rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.65rem;
|
||||
font-size: 0.79rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--ht-cyan);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page-hero h1,
|
||||
.app-topbar h1,
|
||||
.auth-card h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2.4rem, 6vw, 4.8rem);
|
||||
line-height: 0.98;
|
||||
}
|
||||
|
||||
.page-hero__lede,
|
||||
.section-heading p,
|
||||
.hero-copy p,
|
||||
.card p,
|
||||
.callout p,
|
||||
.panel p,
|
||||
.timeline-entry p,
|
||||
.auth-card__subtitle {
|
||||
color: var(--ht-muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.page-section,
|
||||
.site-footer,
|
||||
.auth-card,
|
||||
.app-sidebar,
|
||||
.app-topbar,
|
||||
.panel,
|
||||
.hero-visual-card,
|
||||
.metric-card,
|
||||
.card,
|
||||
.callout,
|
||||
.timeline-entry {
|
||||
border: 1px solid var(--ht-border);
|
||||
background: var(--ht-surface);
|
||||
border-radius: 1.5rem;
|
||||
box-shadow: 0 24px 90px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
padding: clamp(1.25rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.85fr);
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
display: grid;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.hero-visual-card {
|
||||
padding: 1.2rem;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(84, 203, 255, 0.18), transparent 42%),
|
||||
linear-gradient(180deg, rgba(15, 24, 48, 0.96), rgba(10, 16, 30, 0.94));
|
||||
}
|
||||
|
||||
.hero-visual-card__image {
|
||||
width: min(320px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-visual-card__caption {
|
||||
margin: 1rem 0 0;
|
||||
color: var(--ht-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
flex: 1 1 210px;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
display: block;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.metric-card span {
|
||||
color: var(--ht-muted);
|
||||
}
|
||||
|
||||
.page-section {
|
||||
margin-top: 1.4rem;
|
||||
padding: clamp(1.25rem, 3vw, 1.85rem);
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-heading h2,
|
||||
.card h3,
|
||||
.panel h2,
|
||||
.timeline-entry h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.card-grid,
|
||||
.split-grid,
|
||||
.panel-grid {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-grid > *,
|
||||
.split-grid > *,
|
||||
.panel-grid > * {
|
||||
flex: 1 1 280px;
|
||||
}
|
||||
|
||||
.card,
|
||||
.panel,
|
||||
.callout,
|
||||
.timeline-entry {
|
||||
padding: 1.15rem;
|
||||
}
|
||||
|
||||
.card--compact {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.card--pricing {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.top-gap {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0 0 0.6rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: var(--ht-amber-soft);
|
||||
color: var(--ht-amber);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
padding-left: 1.15rem;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
color: var(--ht-muted);
|
||||
}
|
||||
|
||||
.feature-band {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.feature-band__card {
|
||||
flex: 1 1 280px;
|
||||
display: block;
|
||||
padding: 1.2rem;
|
||||
border: 1px solid var(--ht-border);
|
||||
border-radius: 1.3rem;
|
||||
background: rgba(10, 16, 30, 0.56);
|
||||
}
|
||||
|
||||
.feature-band__card--link {
|
||||
transition: border-color 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.feature-band__card--link:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--ht-border-strong);
|
||||
}
|
||||
|
||||
.inline-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.8rem;
|
||||
color: var(--ht-cyan);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.timeline-entry {
|
||||
border-left: 4px solid var(--ht-amber);
|
||||
}
|
||||
|
||||
.timeline-entry__date {
|
||||
margin: 0 0 0.4rem;
|
||||
color: var(--ht-cyan);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
width: min(1180px, calc(100vw - 2rem));
|
||||
margin: 1rem auto 2rem;
|
||||
padding: 1.2rem;
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.site-footer__links {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.site-footer__note {
|
||||
color: var(--ht-muted);
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: min(540px, 100%);
|
||||
padding: 1.6rem;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-size: 0.92rem;
|
||||
color: var(--ht-muted);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
min-height: 2.9rem;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--ht-border);
|
||||
background: rgba(7, 12, 24, 0.7);
|
||||
color: var(--ht-text);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: 2px solid rgba(84, 203, 255, 0.24);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: #ff9f9f;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.provider-row {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.auth-card__footer {
|
||||
margin-top: 1rem;
|
||||
color: var(--ht-muted);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
min-height: 100vh;
|
||||
margin: 0.8rem;
|
||||
padding: 1rem;
|
||||
position: sticky;
|
||||
top: 0.8rem;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.app-sidebar__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-nav__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.app-sidebar__footer {
|
||||
margin-top: 1.25rem;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.app-main-shell {
|
||||
padding: 0.8rem 0.8rem 0.8rem 0;
|
||||
}
|
||||
|
||||
.app-topbar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.2rem;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding-top: 0.9rem;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(3, 9, 20, 0.74);
|
||||
border: 1px solid var(--ht-border);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
color: var(--ht-success);
|
||||
}
|
||||
|
||||
.general-page-loader-shell {
|
||||
position: relative;
|
||||
min-height: calc(100vh - 10rem);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
padding: 1.5rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.general-page-loader-frame {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
min-width: min(24rem, 82vw);
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
border: 1px solid var(--ht-border-strong);
|
||||
border-radius: 1.2rem;
|
||||
padding: 1.35rem 1.2rem 1.1rem;
|
||||
background: rgba(12, 18, 35, 0.72);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.04),
|
||||
0 0 28px rgba(84, 203, 255, 0.15);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.general-page-loader-text {
|
||||
display: inline-flex;
|
||||
font-size: 0.81rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ht-muted);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
color: var(--ht-cyan);
|
||||
text-shadow:
|
||||
0 0 6px rgba(84, 203, 255, 0.9),
|
||||
0 0 18px rgba(84, 203, 255, 0.55),
|
||||
0 0 32px rgba(84, 203, 255, 0.28);
|
||||
}
|
||||
|
||||
.loading-spinner.is-completing {
|
||||
color: var(--ht-amber);
|
||||
text-shadow:
|
||||
0 0 6px rgba(247, 178, 103, 0.95),
|
||||
0 0 18px rgba(247, 178, 103, 0.55),
|
||||
0 0 32px rgba(247, 178, 103, 0.28);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.hero-grid,
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.site-header,
|
||||
.app-topbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.app-main-shell {
|
||||
padding: 0 0.8rem 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.site-header,
|
||||
.page-main,
|
||||
.site-footer {
|
||||
width: min(100vw - 1rem, 100%);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
padding-inline: 0.8rem;
|
||||
}
|
||||
|
||||
.page-main {
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.site-nav,
|
||||
.site-header__actions,
|
||||
.button-row {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
4
website/src/test-setup.ts
Normal file
4
website/src/test-setup.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import matchers from '@testing-library/jest-dom/matchers'
|
||||
import { expect } from 'vitest'
|
||||
|
||||
expect.extend(matchers)
|
||||
1
website/src/vite-env.d.ts
vendored
Normal file
1
website/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
22
website/tsconfig.json
Normal file
22
website/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
website/tsconfig.node.json
Normal file
10
website/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
33
website/vite.config.ts
Normal file
33
website/vite.config.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('supertokens-auth-react')) {
|
||||
return 'auth-vendor'
|
||||
}
|
||||
if (id.includes('react-router-dom') || id.includes('@tanstack/react-query')) {
|
||||
return 'app-vendor'
|
||||
}
|
||||
if (id.includes('lucide-react')) {
|
||||
return 'icon-vendor'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 4273,
|
||||
},
|
||||
preview: {
|
||||
port: 4274,
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue