diff --git a/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_AUTH_BILLING_AND_DISTRIBUTION_PACKET_2026-06-22.md b/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_AUTH_BILLING_AND_DISTRIBUTION_PACKET_2026-06-22.md index 19ed77f..cec390c 100644 --- a/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_AUTH_BILLING_AND_DISTRIBUTION_PACKET_2026-06-22.md +++ b/docs/ops/HYPERTWIST_PUBLIC_WEBSITE_AUTH_BILLING_AND_DISTRIBUTION_PACKET_2026-06-22.md @@ -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 diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md index 3471cbc..04ca576 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md @@ -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 diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md index 2cb7c51..9600df8 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md @@ -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 diff --git a/website/README.md b/website/README.md index 5c6c8d7..d14e0eb 100644 --- a/website/README.md +++ b/website/README.md @@ -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` diff --git a/website/server/README.md b/website/server/README.md index 6a1b2cd..8f7d914 100644 --- a/website/server/README.md +++ b/website/server/README.md @@ -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: diff --git a/website/server/src/__tests__/billing-state.test.ts b/website/server/src/__tests__/billing-state.test.ts index 4af4371..86242ab 100644 --- a/website/server/src/__tests__/billing-state.test.ts +++ b/website/server/src/__tests__/billing-state.test.ts @@ -171,6 +171,7 @@ describe('createBillingStateStore', () => { plan: 'operator', canDownload: true, transactionId: 'txn_123', + subscriptionId: null, customerId: 'ctm_456', }) diff --git a/website/server/src/__tests__/server-bootstrap.test.ts b/website/server/src/__tests__/server-bootstrap.test.ts index 5cdb824..b515c71 100644 --- a/website/server/src/__tests__/server-bootstrap.test.ts +++ b/website/server/src/__tests__/server-bootstrap.test.ts @@ -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 @@ -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) + } + }) }) diff --git a/website/server/src/__tests__/test-session.test.ts b/website/server/src/__tests__/test-session.test.ts new file mode 100644 index 0000000..aec0f12 --- /dev/null +++ b/website/server/src/__tests__/test-session.test.ts @@ -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', + }) + }) +}) diff --git a/website/server/src/billing-state.ts b/website/server/src/billing-state.ts index fbd6154..74667b7 100644 --- a/website/server/src/billing-state.ts +++ b/website/server/src/billing-state.ts @@ -177,14 +177,16 @@ function readEmailFromEventData(data: Record): string { function readAllCandidateIds(data: Record, key: 'subscriptionId' | 'transactionId' | 'customerId') { const pathCandidates: Record>> = { 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'], diff --git a/website/server/src/index.ts b/website/server/src/index.ts index 58cf575..c62ee77 100644 --- a/website/server/src/index.ts +++ b/website/server/src/index.ts @@ -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 const Google = GoogleProvider as unknown as (options: { clientId: string; clientSecret: string; scope?: string[] }) => ReturnType @@ -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) { } } +async function getRequestSession(req: SessionRequest, res: express.Response): Promise { + 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' }) } diff --git a/website/server/src/test-session.ts b/website/server/src/test-session.ts new file mode 100644 index 0000000..994f8ba --- /dev/null +++ b/website/server/src/test-session.ts @@ -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 + mergeIntoAccessTokenPayload(nextPayload: Record): Promise + revokeSession(): Promise +} + +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 + 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 = { + 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) { + Object.assign(accessTokenPayload, nextPayload) + }, + async revokeSession() { + Object.keys(accessTokenPayload).forEach((key) => { + delete accessTokenPayload[key] + }) + }, + } +}