Prove live website session-backed auth routes

This commit is contained in:
axiomlogicnexus 2026-06-22 05:55:34 +00:00
parent 114232c579
commit 15bb11f994
11 changed files with 415 additions and 12 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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`

View file

@ -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:

View file

@ -171,6 +171,7 @@ describe('createBillingStateStore', () => {
plan: 'operator',
canDownload: true,
transactionId: 'txn_123',
subscriptionId: null,
customerId: 'ctm_456',
})

View file

@ -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)
}
})
})

View 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',
})
})
})

View file

@ -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'],

View file

@ -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' })
}

View 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]
})
},
}
}