import { randomBytes, timingSafeEqual } from 'node:crypto'; import path from 'node:path'; import type { IncomingMessage } from 'node:http'; import type { AgentBrowserLifecycleEvent, } from '../agent-browser/module'; import type { CodingProjectService } from '../coding-projects/project-service'; import { getWorksSquareAccountBinding, getWorksSquareSessionSnapshot, isCurrentWorksSquareAccountBinding, subscribeWorksSquareSession, type WorksSquareAccountBinding, type WorksSquareSessionListener, } from './works-square-session'; import { getPort } from '../utils/config'; export const PREVIEW_DATA_ROUTE_ROOT = '/api/runtime/data/v1'; export const PREVIEW_DATA_CONTRACT_VERSION = 1 as const; export const PREVIEW_DATA_MAX_REQUEST_BYTES = 98_304; export const PREVIEW_DATA_MAX_RESPONSE_BYTES = 1_310_720; export const PREVIEW_DATA_RATE = 5; export const PREVIEW_DATA_BUCKET_CAPACITY = 30; const PREVIEW_DATA_METHODS = new Set(['GET', 'PUT', 'DELETE', 'OPTIONS']); const PREVIEW_DATA_HEADERS = new Set(['authorization', 'content-type', 'if-match']); const PREVIEW_DATA_TRANSPORT_HEADERS = new Set([ 'accept', 'accept-encoding', 'accept-language', 'cache-control', 'connection', 'content-length', 'host', 'origin', 'priority', 'pragma', 'referer', 'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform', 'sec-fetch-dest', 'sec-fetch-mode', 'sec-fetch-site', 'sec-fetch-user', 'transfer-encoding', 'upgrade-insecure-requests', 'user-agent', ]); export type PreviewDataSessionInvalidationReason = | 'preview_closed' | 'project_deactivated' | 'project_identity_changed' | 'session_cleared' | 'account_changed' | 'cross_origin_navigation' | 'browser_generation_replaced' | 'browser_detached' | 'browser_crashed' | 'main_shutdown' | 'replaced' | 'manual'; export type PreviewDataSessionInvalidationListener = ( reason: PreviewDataSessionInvalidationReason, ) => void; export type PreviewDataSessionOpenInput = { projectPath: string; origin: string; browserGeneration: number; }; export type PreviewDataSessionSnapshot = Readonly<{ projectPath: string; projectId: string; origin: string; browserGeneration: number; createdAt: number; }>; export type PreviewDataInjectionValue = Readonly<{ endpoint: string; token: string; contractVersion: typeof PREVIEW_DATA_CONTRACT_VERSION; }>; export type PreviewDataAuthorizationSuccess = Readonly<{ ok: true; method: string; session: PreviewDataSessionSnapshot; }>; export type PreviewDataAuthorizationFailure = Readonly<{ ok: false; status: number; code: string; message: string; retryable: boolean; retryAfterSeconds?: number; }>; export type PreviewDataAuthorizationResult = | PreviewDataAuthorizationSuccess | PreviewDataAuthorizationFailure; export class PreviewDataSessionError extends Error { constructor( readonly code: 'invalid_origin' | 'invalid_generation' | 'runtime_unavailable', message: string, ) { super(message); this.name = 'PreviewDataSessionError'; } } export type PreviewDataSessionManagerOptions = { projects: Pick; hostPort?: number; now?: () => number; randomBytes?: (size: number) => Buffer; subscribeWorksSquareSession?: typeof subscribeWorksSquareSession; getWorksSquareSessionSnapshot?: typeof getWorksSquareSessionSnapshot; getWorksSquareAccountBinding?: typeof getWorksSquareAccountBinding; isCurrentWorksSquareAccountBinding?: typeof isCurrentWorksSquareAccountBinding; }; type InternalSession = { snapshot: PreviewDataSessionSnapshot; token: string; tokenBytes: Buffer; tokens: number; lastRefillMs: number; }; function normalizePath(value: string): string { const resolved = path.resolve(value); return process.platform === 'win32' ? resolved.toLocaleLowerCase('en-US') : resolved; } function samePath(left: string, right: string): boolean { return normalizePath(left) === normalizePath(right); } function normalizeOrigin(value: string): string { let parsed: URL; try { parsed = new URL(value); } catch { throw new PreviewDataSessionError('invalid_origin', 'Preview Origin is invalid'); } const hostname = parsed.hostname.toLowerCase(); const isLoopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'; if ( !isLoopback || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash ) { throw new PreviewDataSessionError('invalid_origin', 'Preview Origin is invalid'); } return parsed.origin; } function normalizeGeneration(value: number): number { if (!Number.isSafeInteger(value) || value < 1) { throw new PreviewDataSessionError('invalid_generation', 'Browser generation is invalid'); } return value; } function headerValue(req: IncomingMessage, name: string): string | undefined { const value = req.headers[name]; return typeof value === 'string' ? value : undefined; } function failure( status: number, code: string, message: string, retryable = false, retryAfterSeconds?: number, ): PreviewDataAuthorizationFailure { return { ok: false, status, code, message, retryable, ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }), }; } function requestHeadersAllowed(req: IncomingMessage): boolean { for (const [name, value] of Object.entries(req.headers)) { if (Array.isArray(value)) return false; if (PREVIEW_DATA_HEADERS.has(name) || PREVIEW_DATA_TRANSPORT_HEADERS.has(name)) continue; if (name === 'access-control-request-method') { if (value !== undefined && !PREVIEW_DATA_METHODS.has(value.toUpperCase())) return false; continue; } if (name === 'access-control-request-headers') { if (value === undefined) continue; const requested = value.split(',').map((item) => item.trim().toLowerCase()).filter(Boolean); if (requested.some((item) => !PREVIEW_DATA_HEADERS.has(item))) return false; continue; } return false; } return true; } function isExactToken(candidate: string, expected: Buffer): boolean { if (!candidate || !/^[A-Za-z0-9_-]+$/.test(candidate)) return false; const candidateBytes = Buffer.from(candidate, 'base64url'); return candidateBytes.length === expected.length && timingSafeEqual(candidateBytes, expected); } function retryAfterSeconds(tokens: number): number { return Math.max(1, Math.ceil((1 - tokens) / PREVIEW_DATA_RATE)); } export class PreviewDataSessionManager { private readonly projects: Pick; private readonly hostPort: number; private readonly now: () => number; private readonly createRandomBytes: (size: number) => Buffer; private readonly getSessionSnapshot: typeof getWorksSquareSessionSnapshot; private readonly getAccountBinding: typeof getWorksSquareAccountBinding; private readonly isCurrentAccountBinding: typeof isCurrentWorksSquareAccountBinding; private readonly unsubscribeWorksSession: () => void; private readonly invalidationListeners = new Set(); private session: InternalSession | null = null; private boundAccountBinding: WorksSquareAccountBinding | null; private disposed = false; constructor(options: PreviewDataSessionManagerOptions) { this.projects = options.projects; this.hostPort = options.hostPort ?? getPort('NIANCODE_HOST_API'); this.now = options.now ?? Date.now; this.createRandomBytes = options.randomBytes ?? randomBytes; this.getSessionSnapshot = options.getWorksSquareSessionSnapshot ?? getWorksSquareSessionSnapshot; this.getAccountBinding = options.getWorksSquareAccountBinding ?? getWorksSquareAccountBinding; this.isCurrentAccountBinding = options.isCurrentWorksSquareAccountBinding ?? isCurrentWorksSquareAccountBinding; // The Works observer intentionally has no initial callback. Read the // current snapshot once, then rely on the unsubscribe-able observer for // subsequent logout/account transitions. const initialSession = this.getSessionSnapshot(); this.boundAccountBinding = initialSession ? this.getAccountBinding() : null; const subscribe = options.subscribeWorksSquareSession ?? subscribeWorksSquareSession; const listener: WorksSquareSessionListener = (nextSession) => { if (!nextSession) { this.invalidate('session_cleared'); return; } if (!this.boundAccountBinding) { this.invalidate('account_changed'); return; } if (!this.isCurrentAccountBinding(this.boundAccountBinding)) { this.invalidate('account_changed'); } }; this.unsubscribeWorksSession = subscribe(listener); } async open(input: PreviewDataSessionOpenInput): Promise { if (this.disposed) { throw new PreviewDataSessionError('runtime_unavailable', 'Preview data runtime is unavailable'); } const origin = normalizeOrigin(input.origin); const browserGeneration = normalizeGeneration(input.browserGeneration); const active = await this.projects.requireActiveRealProjectWithIdentity(input.projectPath); if (this.disposed) { throw new PreviewDataSessionError('runtime_unavailable', 'Preview data runtime is unavailable'); } const now = this.now(); const tokenBytes = this.createRandomBytes(32); if (tokenBytes.length !== 32) { throw new PreviewDataSessionError('runtime_unavailable', 'Preview data runtime is unavailable'); } const token = tokenBytes.toString('base64url'); this.invalidate('replaced'); const snapshot: PreviewDataSessionSnapshot = Object.freeze({ projectPath: active.path, projectId: active.projectId, origin, browserGeneration, createdAt: now, }); this.session = { snapshot, token, tokenBytes, tokens: PREVIEW_DATA_BUCKET_CAPACITY, lastRefillMs: now, }; this.boundAccountBinding = this.getAccountBinding(); return snapshot; } getSnapshot(): PreviewDataSessionSnapshot | null { return this.session?.snapshot ?? null; } subscribeInvalidation(listener: PreviewDataSessionInvalidationListener): () => void { if (this.disposed) return () => undefined; this.invalidationListeners.add(listener); return () => { this.invalidationListeners.delete(listener); }; } getInjectionValue(hostPort = this.hostPort): PreviewDataInjectionValue | null { const session = this.session; if (!session || !Number.isSafeInteger(hostPort) || hostPort < 1 || hostPort > 65_535) { return null; } return Object.freeze({ endpoint: `http://127.0.0.1:${hostPort}${PREVIEW_DATA_ROUTE_ROOT}`, token: session.token, contractVersion: PREVIEW_DATA_CONTRACT_VERSION, }); } private accountBindingIsCurrent(): boolean { const currentSession = this.getSessionSnapshot(); const currentBinding = this.getAccountBinding(); if (!this.boundAccountBinding) return !currentSession && !currentBinding; return Boolean(currentBinding && this.isCurrentAccountBinding(this.boundAccountBinding)); } corsOrigin(req: IncomingMessage): string | null { const origin = headerValue(req, 'origin'); const session = this.session; return session && origin === session.snapshot.origin ? origin : null; } authorizeRequest(req: IncomingMessage): PreviewDataAuthorizationResult { const session = this.session; if (!session) return failure(503, 'runtime_unavailable', 'Preview data runtime is unavailable', true); if (!this.accountBindingIsCurrent()) { this.invalidate('account_changed'); return failure(503, 'runtime_unavailable', 'Preview data runtime is unavailable', true); } const origin = headerValue(req, 'origin'); if (origin !== session.snapshot.origin) { return failure(403, 'origin_not_allowed', 'Preview Origin is not allowed'); } if (!requestHeadersAllowed(req)) { return failure(400, 'invalid_request', 'Preview request headers are not allowed'); } const method = (req.method ?? '').toUpperCase(); if (method === 'OPTIONS') { return { ok: true, method, session: session.snapshot }; } const authorization = headerValue(req, 'authorization') ?? ''; if (!authorization.startsWith('Bearer ') || !isExactToken(authorization.slice(7), session.tokenBytes)) { return failure(401, 'authentication_required', 'Preview data authorization is required'); } if (!PREVIEW_DATA_METHODS.has(method)) { return failure(405, 'method_not_allowed', 'Preview data method is not allowed'); } const now = this.now(); const elapsed = Math.max(0, now - session.lastRefillMs); session.tokens = Math.min( PREVIEW_DATA_BUCKET_CAPACITY, session.tokens + elapsed / 1_000 * PREVIEW_DATA_RATE, ); session.lastRefillMs = now; if (session.tokens < 1) { return failure( 429, 'rate_limited', 'Preview data request rate limit exceeded', true, retryAfterSeconds(session.tokens), ); } session.tokens -= 1; return { ok: true, method, session: session.snapshot }; } isCurrentSession(snapshot: PreviewDataSessionSnapshot): boolean { return this.session?.snapshot === snapshot; } handleAgentBrowserLifecycle(event: AgentBrowserLifecycleEvent): void { const session = this.session; if (!session || !samePath(session.snapshot.projectPath, event.projectPath)) return; if (event.generation !== session.snapshot.browserGeneration) return; switch (event.type) { case 'closed': this.invalidate('preview_closed'); break; case 'cross-origin-navigation': this.invalidate('cross_origin_navigation'); break; case 'generation-replaced': this.invalidate('browser_generation_replaced'); break; case 'detached': this.invalidate('browser_detached'); break; case 'crashed': this.invalidate('browser_crashed'); break; default: break; } } invalidate(reason: PreviewDataSessionInvalidationReason = 'manual'): void { if (!this.session) return; this.session = null; for (const listener of this.invalidationListeners) { try { listener(reason); } catch { // A browser cleanup observer must not interrupt token invalidation. } } } dispose(): void { if (this.disposed) return; this.disposed = true; this.invalidate('main_shutdown'); this.unsubscribeWorksSession(); this.invalidationListeners.clear(); } } export function createPreviewDataSessionManager( options: PreviewDataSessionManagerOptions, ): PreviewDataSessionManager { return new PreviewDataSessionManager(options); }