Prove live website session-backed auth routes
This commit is contained in:
parent
114232c579
commit
15bb11f994
11 changed files with 415 additions and 12 deletions
|
|
@ -168,7 +168,7 @@ The frontend behavior coverage now also explicitly pins:
|
|||
- login/register unhappy-path coverage for returned form errors, auth-runtime warning callouts, and OAuth-button visibility/invocation
|
||||
- dashboard launch-readiness visibility plus generated desktop-link verify URL behavior
|
||||
- real `check-runtime-readiness` CLI proof against the checked-in `.env.production.example` files so placeholder launch scaffolds still fail the documented deploy-time command
|
||||
- spawned `website/server` bootstrap proof from production-shaped same-origin env into live `/health`, `/api/auth/health`, same-origin built-shell delivery, and verified webhook reflection into processed-event health plus persisted billing state
|
||||
- spawned `website/server` bootstrap proof from production-shaped same-origin env into live `/health`, `/api/auth/health`, same-origin built-shell delivery, verified webhook reflection into processed-event health plus persisted billing state, and bounded session-backed `/api/auth/me` plus `/api/auth/desktop-link` behavior through a validation-only signed test-session harness under `TEST_MODE=testing`
|
||||
|
||||
The first-party auth server now also supports bounded same-origin public serving
|
||||
for `hypertwist.app` when a built `website/dist/index.html` is present:
|
||||
|
|
@ -280,6 +280,7 @@ Validation result on `2026-06-22`:
|
|||
- the spawned same-origin auth-server bootstrap proof passed
|
||||
- the real runtime-readiness CLI against the checked-in production example env files failed exactly on the remaining placeholder launch values
|
||||
- the spawned server also accepted a real signed Paddle test payload and reflected the processed billing event through both `/api/auth/health` and the persisted billing-state file
|
||||
- the spawned server also proved bounded session-backed `/api/auth/me` and `/api/auth/desktop-link` behavior under the validation-only signed test-session harness, and transaction events no longer leak their id into stored `subscription_id`
|
||||
|
||||
## Explicit non-claims
|
||||
|
||||
|
|
|
|||
|
|
@ -264,9 +264,9 @@ repo.
|
|||
| Feature | Status | Primary authority | Notes |
|
||||
|---|---|---|---|
|
||||
| Public `hypertwist.app` marketing shell | Implemented now | first-party `website/` app + feature registry/roadmap authority | HyperTwist now has a dedicated first-party public web surface for homepage, about, resources, pricing, download, support, and legal routes. This lane is separate from the embedded Unreal browser runtime under `Content/Browser/` and does not claim browser-simulator parity. The same package now also carries a first-party external runtime-readiness verifier so deploy-time env and live health posture can be checked outside the dashboard, plus separated local-versus-production env templates whose placeholder values are intentionally rejected until real launch config is in place, bootstrap CI now validates both the frontend and auth-server website commands directly, and the auth server can now auto-serve the built `website/dist` bundle with bounded SPA fallback for same-origin public deployment. Request-level server coverage now also proves that public/app shell delivery does not shadow `/api/*`, `/auth*`, `/health`, or missing asset paths, while the pricing/download/notices routes now surface first-party preview-versus-launch posture from the same bounded launch checklist instead of relying on hidden operator-only status. The real `check-runtime-readiness` CLI is now also exercised against the checked-in production example env files, and a spawned `website/server` bootstrap proof now verifies the live same-origin process path from production-shaped env into `/health`, `/api/auth/health`, and built-shell serving. |
|
||||
| Browser-based operator/account dashboard | Implemented now | first-party `website/` app + shared auth/dashboard packet | A protected browser dashboard is now live for operator access, account state, download posture, browser-access boundary explanation, notices review, and bounded billing/entitlement status. It reuses the shared SuperTokens auth posture proven in FamiliarOS and ScriptoriumAI while remaining HyperTwist-specific in product content and boundary claims, the current auth-health surface now truthfully distinguishes configured versus reachable or ready shared-core posture while exposing fallback-active reason instead of hardcoding readiness, and the same dashboard now also surfaces launch-readiness truth for download URLs, checkout links, source/notices URLs, billing-secret/map configuration, and local-versus-public runtime deployment posture. Focused frontend coverage now also protects deep-link login redirect preservation, safe `next`-path normalization across auth entry points, fallback/email auth-bootstrap normalization, login/register continuation behavior, public download-gating behavior, protected-route/shell behavior, real lazy-route tree behavior for key public and protected paths, top-level app-bootstrap and SuperTokens-wrapper posture, login/register unhappy-path and OAuth-button behavior, support-topic fallback routing when live checkout is not configured, and desktop-link verify-url/dashboard readiness behavior. |
|
||||
| Browser-based operator/account dashboard | Implemented now | first-party `website/` app + shared auth/dashboard packet | A protected browser dashboard is now live for operator access, account state, download posture, browser-access boundary explanation, notices review, and bounded billing/entitlement status. It reuses the shared SuperTokens auth posture proven in FamiliarOS and ScriptoriumAI while remaining HyperTwist-specific in product content and boundary claims, the current auth-health surface now truthfully distinguishes configured versus reachable or ready shared-core posture while exposing fallback-active reason instead of hardcoding readiness, and the same dashboard now also surfaces launch-readiness truth for download URLs, checkout links, source/notices URLs, billing-secret/map configuration, and local-versus-public runtime deployment posture. Focused frontend coverage now also protects deep-link login redirect preservation, safe `next`-path normalization across auth entry points, fallback/email auth-bootstrap normalization, login/register continuation behavior, public download-gating behavior, protected-route/shell behavior, real lazy-route tree behavior for key public and protected paths, top-level app-bootstrap and SuperTokens-wrapper posture, login/register unhappy-path and OAuth-button behavior, support-topic fallback routing when live checkout is not configured, and desktop-link verify-url/dashboard readiness behavior. The validation lane now also has a bounded signed test-session harness under `TEST_MODE=testing` that proves `/api/auth/me` and `/api/auth/desktop-link` behavior through the live spawned auth-server process without widening production auth posture. |
|
||||
| Desktop download posture and browser-to-desktop pairing | Implemented now | first-party `website/` app + `website/server` desktop-link endpoints | Public download targets, dashboard-side release posture, and short-lived desktop-link token generation/verification are now first-party owned. The current server posture now enforces exact website-origin matching, bounded per-user issuance, one-time token consumption, and billing-backed plan/download entitlement resolution with focused `website/server` tests green on `2026-06-22`, and the verify handshake now returns the same resolved download-entitlement posture the dashboard sees instead of only identity plus plan/role. The public `/download` page now keeps raw download URLs behind the protected dashboard instead of exposing them directly, preserves requested platform continuity through `/app/downloads?platform=...`, and surfaces that requested target again after auth handoff inside the protected release lane. 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`. A spawned live-process proof now also verifies that a real signed webhook updates processed-event health and persisted billing state through the actual auth-server runtime, not only helper-level store tests. Production checkout URLs, secret management, and broader operator/admin billing workflows remain deployment/application tasks, not shipped-code omissions. |
|
||||
| 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`. A spawned live-process proof now also verifies that a real signed webhook updates processed-event health and persisted billing state through the actual auth-server runtime, not only helper-level store tests, and transaction events no longer leak their id into stored `subscriptionId` state. 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
|
||||
|
|
|
|||
|
|
@ -235,8 +235,10 @@ Current consolidated milestone snapshot:
|
|||
`check-runtime-readiness` CLI proof against the checked-in production example
|
||||
env files and spawned `website/server` bootstrap proof from
|
||||
production-shaped same-origin env into live `/health`, `/api/auth/health`,
|
||||
built-shell delivery, and verified webhook reflection into live billing
|
||||
health/state,
|
||||
built-shell delivery, verified webhook reflection into live billing
|
||||
health/state, and bounded session-backed `/api/auth/me` plus
|
||||
`/api/auth/desktop-link` proof through a validation-only signed
|
||||
test-session harness under `TEST_MODE=testing`,
|
||||
and the auth server can now auto-serve the built
|
||||
`website/dist` bundle with bounded SPA fallback for same-origin `hypertwist.app`
|
||||
deployment when that build output is present, while the env templates and
|
||||
|
|
|
|||
|
|
@ -106,4 +106,4 @@ The focused frontend test coverage now also pins:
|
|||
- top-level `App` bootstrap coverage for unknown-route redirect and SuperTokens wrapper on/off posture
|
||||
- login/register unhappy-path coverage for returned form errors, auth-runtime warning callouts, and OAuth-button visibility/invocation
|
||||
- dashboard launch-readiness plus desktop-link verify-url behavior
|
||||
- spawned `website/server` bootstrap proof from production-shaped same-origin env into live `/health`, `/api/auth/health`, static public/app shell delivery, and verified webhook reflection into billing state
|
||||
- spawned `website/server` bootstrap proof from production-shaped same-origin env into live `/health`, `/api/auth/health`, static public/app shell delivery, verified webhook reflection into billing state, and bounded session-backed `/api/auth/me` plus `/api/auth/desktop-link` behavior under `TEST_MODE=testing`
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ The server now also supports a bounded first-party same-origin deployment mode:
|
|||
- the example env files now carry those static-serving controls directly so deployment posture is not implicit
|
||||
- request-level tests now also prove that public/app routes serve the shell while `/api/*`, `/auth*`, `/health`, and missing asset paths remain unshadowed
|
||||
- a spawned bootstrap test now also proves the real `src/index.ts` process can start from production-shaped same-origin env, reach a ready SuperTokens core, expose green `/api/auth/health`, serve the built shell, accept a verified webhook, and reflect that processed billing event through live health plus the persisted entitlement file
|
||||
- the same spawned proof lane now also exercises `/api/auth/me` and `/api/auth/desktop-link` through a bounded signed test-session harness that is only active when `TEST_MODE=testing` and `HYPERTWIST_TEST_SESSION_SECRET` are explicitly set; this is validation-only, not a production auth widening
|
||||
|
||||
The website package now also ships a deploy-time verification command:
|
||||
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ describe('createBillingStateStore', () => {
|
|||
plan: 'operator',
|
||||
canDownload: true,
|
||||
transactionId: 'txn_123',
|
||||
subscriptionId: null,
|
||||
customerId: 'ctm_456',
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import os from 'node:os'
|
|||
import { fileURLToPath } from 'node:url'
|
||||
import type { Readable } from 'node:stream'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createSignedTestSessionValue } from '../test-session'
|
||||
|
||||
const tempRoots: string[] = []
|
||||
type SpawnedServerProcess = ChildProcessByStdio<null, Readable, Readable>
|
||||
|
|
@ -425,4 +426,156 @@ describe('website/server bootstrap', () => {
|
|||
await stopChildProcess(child)
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves /api/auth/me and /api/auth/desktop-link through the bounded testing-session harness after entitlement state is applied', async () => {
|
||||
const coreHost = getNonLoopbackIpv4()
|
||||
if (!coreHost) {
|
||||
throw new Error('A non-loopback IPv4 interface is required for public-posture bootstrap proof.')
|
||||
}
|
||||
|
||||
const root = makeTempRoot()
|
||||
createDistBundle(root)
|
||||
const billingStatePath = path.join(root, 'billing-state.json')
|
||||
const fakeCore = await startFakeCoreServer(coreHost)
|
||||
const serverPort = await getFreePort('127.0.0.1')
|
||||
const baseUrl = `http://127.0.0.1:${serverPort}`
|
||||
const webhookSecret = 'real-secret-value'
|
||||
const testSessionSecret = 'test-session-secret'
|
||||
|
||||
const { child, logs } = spawnAuthServer({
|
||||
...process.env,
|
||||
PORT: String(serverPort),
|
||||
TEST_MODE: 'testing',
|
||||
HYPERTWIST_TEST_SESSION_SECRET: testSessionSecret,
|
||||
SUPERTOKENS_CORE_URI: fakeCore.connectionUri,
|
||||
API_DOMAIN: 'https://hypertwist.app',
|
||||
WEBSITE_DOMAIN: 'https://hypertwist.app',
|
||||
API_BASE_PATH: '/auth',
|
||||
WEBSITE_BASE_PATH: '/auth',
|
||||
COOKIE_SECURE: 'true',
|
||||
SERVE_STATIC_WEBSITE: 'true',
|
||||
WEBSITE_DIST_PATH: path.join(root, 'dist'),
|
||||
DEFAULT_PLAN: 'free',
|
||||
DEFAULT_ROLE: 'operator',
|
||||
SUPERTOKENS_HEALTH_TIMEOUT_MS: '1500',
|
||||
DESKTOP_LINK_RATE_LIMIT_MAX: '5',
|
||||
DESKTOP_LINK_RATE_LIMIT_WINDOW_MS: '900000',
|
||||
PADDLE_WEBHOOK_SECRET: webhookSecret,
|
||||
BILLING_STATE_PATH: billingStatePath,
|
||||
PADDLE_PRODUCT_PLAN_MAP: '{"prod_operator":"operator","prod_studio":"studio"}',
|
||||
PADDLE_PRICE_PLAN_MAP: '{"pri_operator":"operator","pri_studio":"studio"}',
|
||||
GITHUB_CLIENT_ID: '',
|
||||
GITHUB_CLIENT_SECRET: '',
|
||||
GOOGLE_CLIENT_ID: '',
|
||||
GOOGLE_CLIENT_SECRET: '',
|
||||
})
|
||||
|
||||
try {
|
||||
await waitForHttpReady(baseUrl, child, logs)
|
||||
|
||||
const rawBody = JSON.stringify({
|
||||
event_id: 'evt_session_user',
|
||||
event_type: 'transaction.completed',
|
||||
occurred_at: '2026-06-22T04:10:00.000Z',
|
||||
data: {
|
||||
id: 'txn_session_123',
|
||||
customer_id: 'ctm_session_123',
|
||||
custom_data: {
|
||||
email: 'operator@hypertwist.app',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
price: {
|
||||
id: 'pri_operator',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const timestamp = String(Math.floor(Date.now() / 1_000))
|
||||
const webhookResponse = await fetch(`${baseUrl}/api/billing/paddle/webhook`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'paddle-signature': buildPaddleSignatureHeader(rawBody, webhookSecret, timestamp),
|
||||
},
|
||||
body: rawBody,
|
||||
})
|
||||
expect(webhookResponse.status).toBe(200)
|
||||
|
||||
const testSessionHeader = createSignedTestSessionValue({
|
||||
userId: 'user-session-1',
|
||||
email: 'operator@hypertwist.app',
|
||||
plan: 'free',
|
||||
role: 'operator',
|
||||
name: 'Operator Session',
|
||||
authMethod: 'email',
|
||||
}, testSessionSecret)
|
||||
|
||||
const meResponse = await fetch(`${baseUrl}/api/auth/me`, {
|
||||
headers: {
|
||||
'x-hypertwist-test-session': testSessionHeader,
|
||||
},
|
||||
})
|
||||
expect(meResponse.status).toBe(200)
|
||||
expect(await meResponse.json()).toEqual({
|
||||
user: {
|
||||
id: 'user-session-1',
|
||||
email: 'operator@hypertwist.app',
|
||||
name: 'Operator Session',
|
||||
plan: 'operator',
|
||||
role: 'operator',
|
||||
can_download: true,
|
||||
billing: {
|
||||
source: 'paddle',
|
||||
access_status: 'completed',
|
||||
can_download: true,
|
||||
subscription_id: null,
|
||||
customer_id: 'ctm_session_123',
|
||||
transaction_id: 'txn_session_123',
|
||||
last_event_id: 'evt_session_user',
|
||||
last_event_type: 'transaction.completed',
|
||||
last_event_at: '2026-06-22T04:10:00.000Z',
|
||||
updated_at: expect.any(String),
|
||||
state_path: billingStatePath,
|
||||
},
|
||||
auth_method: 'supertokens',
|
||||
},
|
||||
mode: 'supertokens',
|
||||
})
|
||||
|
||||
const desktopLinkResponse = await fetch(`${baseUrl}/api/auth/desktop-link`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
origin: 'https://hypertwist.app',
|
||||
'content-type': 'application/json',
|
||||
'x-hypertwist-test-session': testSessionHeader,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(desktopLinkResponse.status).toBe(200)
|
||||
const desktopLinkPayload = await desktopLinkResponse.json() as {
|
||||
ok: true
|
||||
token: string
|
||||
expires_at: string
|
||||
}
|
||||
expect(desktopLinkPayload.ok).toBe(true)
|
||||
expect(typeof desktopLinkPayload.token).toBe('string')
|
||||
expect(typeof desktopLinkPayload.expires_at).toBe('string')
|
||||
|
||||
const verifyResponse = await fetch(`${baseUrl}/api/auth/desktop-link/verify?token=${encodeURIComponent(desktopLinkPayload.token)}`)
|
||||
expect(verifyResponse.status).toBe(200)
|
||||
expect(await verifyResponse.json()).toEqual({
|
||||
ok: true,
|
||||
email: 'operator@hypertwist.app',
|
||||
plan: 'operator',
|
||||
role: 'operator',
|
||||
can_download: true,
|
||||
access_status: 'completed',
|
||||
})
|
||||
} finally {
|
||||
await stopChildProcess(child)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
99
website/server/src/__tests__/test-session.test.ts
Normal file
99
website/server/src/__tests__/test-session.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { Request } from 'express'
|
||||
|
||||
import {
|
||||
TEST_SESSION_HEADER,
|
||||
createSessionLikeFromTestPayload,
|
||||
createSignedTestSessionValue,
|
||||
readSignedTestSessionFromRequest,
|
||||
verifySignedTestSessionValue,
|
||||
} from '../test-session'
|
||||
|
||||
function createRequestWithHeader(value: string): Request {
|
||||
return {
|
||||
header(name: string) {
|
||||
return name.toLowerCase() === TEST_SESSION_HEADER ? value : undefined
|
||||
},
|
||||
} as Request
|
||||
}
|
||||
|
||||
describe('test-session helpers', () => {
|
||||
it('signs and verifies bounded testing-session payloads', () => {
|
||||
const signedValue = createSignedTestSessionValue({
|
||||
userId: 'user-1',
|
||||
email: 'operator@hypertwist.app',
|
||||
plan: 'operator',
|
||||
role: 'operator',
|
||||
name: 'Operator',
|
||||
authMethod: 'email',
|
||||
}, 'secret')
|
||||
|
||||
expect(verifySignedTestSessionValue({
|
||||
headerValue: signedValue,
|
||||
secret: 'secret',
|
||||
})).toEqual({
|
||||
userId: 'user-1',
|
||||
email: 'operator@hypertwist.app',
|
||||
plan: 'operator',
|
||||
role: 'operator',
|
||||
name: 'Operator',
|
||||
authMethod: 'email',
|
||||
})
|
||||
|
||||
expect(readSignedTestSessionFromRequest(createRequestWithHeader(signedValue), 'secret')).toEqual({
|
||||
userId: 'user-1',
|
||||
email: 'operator@hypertwist.app',
|
||||
plan: 'operator',
|
||||
role: 'operator',
|
||||
name: 'Operator',
|
||||
authMethod: 'email',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed or mismatched testing-session headers', () => {
|
||||
const signedValue = createSignedTestSessionValue({
|
||||
userId: 'user-2',
|
||||
email: 'operator@hypertwist.app',
|
||||
}, 'secret')
|
||||
|
||||
expect(verifySignedTestSessionValue({
|
||||
headerValue: `${signedValue}tampered`,
|
||||
secret: 'secret',
|
||||
})).toBeNull()
|
||||
expect(verifySignedTestSessionValue({
|
||||
headerValue: signedValue,
|
||||
secret: 'wrong-secret',
|
||||
})).toBeNull()
|
||||
expect(verifySignedTestSessionValue({
|
||||
headerValue: 'not-a-valid-token',
|
||||
secret: 'secret',
|
||||
})).toBeNull()
|
||||
})
|
||||
|
||||
it('creates a session-like adapter that can merge payload updates', async () => {
|
||||
const session = createSessionLikeFromTestPayload({
|
||||
userId: 'user-3',
|
||||
email: 'operator@hypertwist.app',
|
||||
plan: 'free',
|
||||
role: 'operator',
|
||||
})
|
||||
|
||||
expect(session.getUserId()).toBe('user-3')
|
||||
expect(session.getAccessTokenPayload()).toMatchObject({
|
||||
email: 'operator@hypertwist.app',
|
||||
plan: 'free',
|
||||
role: 'operator',
|
||||
})
|
||||
|
||||
await session.mergeIntoAccessTokenPayload({
|
||||
plan: 'operator',
|
||||
role: 'admin',
|
||||
})
|
||||
|
||||
expect(session.getAccessTokenPayload()).toMatchObject({
|
||||
email: 'operator@hypertwist.app',
|
||||
plan: 'operator',
|
||||
role: 'admin',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -177,14 +177,16 @@ function readEmailFromEventData(data: Record<string, unknown>): string {
|
|||
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'],
|
||||
['subscription', 'id'],
|
||||
['data', 'subscription_id'],
|
||||
['data', 'subscription', 'id'],
|
||||
],
|
||||
transactionId: [
|
||||
['transaction_id'],
|
||||
['id'],
|
||||
['transaction', 'id'],
|
||||
['data', 'transaction_id'],
|
||||
['data', 'transaction', 'id'],
|
||||
],
|
||||
customerId: [
|
||||
['customer_id'],
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { getRuntimeConfigDiagnostics } from './runtime-config'
|
|||
import { buildAllowedOriginMatcher, createDesktopLinkStore } from './security'
|
||||
import { resolveStaticWebsiteConfig } from './static-site'
|
||||
import { registerStaticWebsiteRoutes } from './static-site-routes'
|
||||
import { createSessionLikeFromTestPayload, readSignedTestSessionFromRequest, type SessionLike } from './test-session'
|
||||
|
||||
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>
|
||||
|
|
@ -39,6 +40,8 @@ const SUPERTOKENS_HEALTH_TIMEOUT_MS = Number(process.env.SUPERTOKENS_HEALTH_TIME
|
|||
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 TEST_MODE_ENABLED = String(process.env.TEST_MODE || '').trim().toLowerCase() === 'testing'
|
||||
const HYPERTWIST_TEST_SESSION_SECRET = process.env.HYPERTWIST_TEST_SESSION_SECRET || ''
|
||||
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')
|
||||
|
|
@ -124,6 +127,17 @@ function resolveAccountState(email: string, payload: Record<string, unknown>) {
|
|||
}
|
||||
}
|
||||
|
||||
async function getRequestSession(req: SessionRequest, res: express.Response): Promise<SessionLike | null> {
|
||||
if (TEST_MODE_ENABLED && HYPERTWIST_TEST_SESSION_SECRET.trim()) {
|
||||
const testPayload = readSignedTestSessionFromRequest(req, HYPERTWIST_TEST_SESSION_SECRET)
|
||||
if (testPayload) {
|
||||
return createSessionLikeFromTestPayload(testPayload)
|
||||
}
|
||||
}
|
||||
|
||||
return (await Session.getSession(req, res, { sessionRequired: false })) ?? null
|
||||
}
|
||||
|
||||
const recipeList = [
|
||||
EmailPassword.init({
|
||||
signUpFeature: {
|
||||
|
|
@ -329,7 +343,7 @@ app.get('/api/auth/health', async (_req, res) => {
|
|||
})
|
||||
|
||||
app.get('/api/auth/me', async (req: SessionRequest, res) => {
|
||||
const session = await Session.getSession(req, res, { sessionRequired: false })
|
||||
const session = await getRequestSession(req, res)
|
||||
if (!session) {
|
||||
return res.status(401).json({ error: 'unauthorized' })
|
||||
}
|
||||
|
|
@ -364,7 +378,7 @@ app.get('/api/auth/me', async (req: SessionRequest, res) => {
|
|||
})
|
||||
|
||||
app.post('/api/auth/logout', async (req: SessionRequest, res) => {
|
||||
const session = await Session.getSession(req, res, { sessionRequired: false })
|
||||
const session = await getRequestSession(req, res)
|
||||
if (session) {
|
||||
await session.revokeSession()
|
||||
}
|
||||
|
|
@ -377,7 +391,7 @@ app.post('/api/auth/desktop-link', async (req: SessionRequest, res) => {
|
|||
return res.status(403).json({ error: 'forbidden_origin' })
|
||||
}
|
||||
|
||||
const session = await Session.getSession(req, res, { sessionRequired: false })
|
||||
const session = await getRequestSession(req, res)
|
||||
if (!session) {
|
||||
return res.status(401).json({ error: 'unauthorized' })
|
||||
}
|
||||
|
|
|
|||
130
website/server/src/test-session.ts
Normal file
130
website/server/src/test-session.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import crypto from 'node:crypto'
|
||||
import type { Request } from 'express'
|
||||
|
||||
export const TEST_SESSION_HEADER = 'x-hypertwist-test-session'
|
||||
|
||||
export interface TestSessionPayload {
|
||||
userId: string
|
||||
email: string
|
||||
plan?: string
|
||||
role?: string
|
||||
name?: string
|
||||
authMethod?: string
|
||||
}
|
||||
|
||||
export interface SessionLike {
|
||||
getUserId(): string
|
||||
getAccessTokenPayload(): Record<string, unknown>
|
||||
mergeIntoAccessTokenPayload(nextPayload: Record<string, unknown>): Promise<void>
|
||||
revokeSession(): Promise<void>
|
||||
}
|
||||
|
||||
interface VerifyTestSessionInput {
|
||||
headerValue: string | undefined
|
||||
secret: string
|
||||
}
|
||||
|
||||
function normalizeTrimmed(value: string | undefined) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function encodeBase64Url(value: string) {
|
||||
return Buffer.from(value, 'utf8').toString('base64url')
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string) {
|
||||
return Buffer.from(value, 'base64url').toString('utf8')
|
||||
}
|
||||
|
||||
function buildSignature(encodedPayload: string, secret: string) {
|
||||
return crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(encodedPayload)
|
||||
.digest('base64url')
|
||||
}
|
||||
|
||||
function isValidPayload(input: unknown): input is TestSessionPayload {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const payload = input as Record<string, unknown>
|
||||
return normalizeTrimmed(String(payload.userId || '')) !== '' && normalizeTrimmed(String(payload.email || '')) !== ''
|
||||
}
|
||||
|
||||
export function createSignedTestSessionValue(payload: TestSessionPayload, secret: string) {
|
||||
const encodedPayload = encodeBase64Url(JSON.stringify(payload))
|
||||
const signature = buildSignature(encodedPayload, secret)
|
||||
return `${encodedPayload}.${signature}`
|
||||
}
|
||||
|
||||
export function verifySignedTestSessionValue({
|
||||
headerValue,
|
||||
secret,
|
||||
}: VerifyTestSessionInput): TestSessionPayload | null {
|
||||
const normalizedHeader = normalizeTrimmed(headerValue)
|
||||
const normalizedSecret = normalizeTrimmed(secret)
|
||||
if (!normalizedHeader || !normalizedSecret) {
|
||||
return null
|
||||
}
|
||||
|
||||
const separatorIndex = normalizedHeader.lastIndexOf('.')
|
||||
if (separatorIndex <= 0 || separatorIndex === normalizedHeader.length - 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const encodedPayload = normalizedHeader.slice(0, separatorIndex)
|
||||
const providedSignature = normalizedHeader.slice(separatorIndex + 1)
|
||||
const expectedSignature = buildSignature(encodedPayload, normalizedSecret)
|
||||
|
||||
const providedBuffer = Buffer.from(providedSignature, 'base64url')
|
||||
const expectedBuffer = Buffer.from(expectedSignature, 'base64url')
|
||||
if (providedBuffer.length !== expectedBuffer.length) {
|
||||
return null
|
||||
}
|
||||
if (!crypto.timingSafeEqual(providedBuffer, expectedBuffer)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(decodeBase64Url(encodedPayload))
|
||||
return isValidPayload(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function readSignedTestSessionFromRequest(req: Request, secret: string): TestSessionPayload | null {
|
||||
const headerValue = req.header(TEST_SESSION_HEADER) || undefined
|
||||
return verifySignedTestSessionValue({
|
||||
headerValue,
|
||||
secret,
|
||||
})
|
||||
}
|
||||
|
||||
export function createSessionLikeFromTestPayload(payload: TestSessionPayload): SessionLike {
|
||||
const accessTokenPayload: Record<string, unknown> = {
|
||||
email: payload.email,
|
||||
name: payload.name || payload.email.split('@')[0] || 'operator',
|
||||
plan: payload.plan || 'free',
|
||||
role: payload.role || 'operator',
|
||||
auth_method: payload.authMethod || 'email',
|
||||
}
|
||||
|
||||
return {
|
||||
getUserId() {
|
||||
return payload.userId
|
||||
},
|
||||
getAccessTokenPayload() {
|
||||
return accessTokenPayload
|
||||
},
|
||||
async mergeIntoAccessTokenPayload(nextPayload: Record<string, unknown>) {
|
||||
Object.assign(accessTokenPayload, nextPayload)
|
||||
},
|
||||
async revokeSession() {
|
||||
Object.keys(accessTokenPayload).forEach((key) => {
|
||||
delete accessTokenPayload[key]
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue