Advertisement

← vibesdk

worker/api/handlers/space-preview.ts

7.0 KB Download file
import {
	buildPreviewCookie,
	readPreviewCookie,
	verifySpacePreviewToken,
	type SpacePreviewClaims,
} from '../../utils/spacePreviewToken';
import { isSeparatePreviewDomain } from '../../utils/urls';
import { AppService } from '../../database/services/AppService';
import { RateLimitService } from '../../services/rate-limit/rateLimits';
import { getGlobalConfigurableSettings } from '../../config';
import { RateLimitExceededError } from 'shared/types/errors';
import { JWTUtils } from '../../utils/jwtUtils';

const SPACE_PREVIEW_ROUTE_PATTERN = /^\/space\/([^/]+)\/preview\/([^/]+)(?:\/.*)?$/;

type SpaceNamespace = {
	SPACE_DO?: DurableObjectNamespace;
};

export interface SpacePreviewParams {
	spaceName: string;
	branch: string;
}

export function matchSpacePreviewParams(pathname: string): SpacePreviewParams | null {
	const match = pathname.match(SPACE_PREVIEW_ROUTE_PATTERN);
	if (!match) return null;
	return {
		spaceName: decodeURIComponent(match[1]),
		branch: decodeURIComponent(match[2]),
	};
}

function getSpaceNamespace(env: Env): DurableObjectNamespace | null {
	const spaceNamespace = (env as unknown as SpaceNamespace).SPACE_DO;
	return spaceNamespace ?? null;
}

async function forwardToSpacePreview(
	request: Request,
	env: Env,
	spaceName: string,
): Promise<Response> {
	const namespace = getSpaceNamespace(env);
	if (!namespace) {
		return new Response('Preview unavailable', { status: 404 });
	}
	// Strip `?t=` so the generated app never sees the token.
	const forwardedUrl = new URL(request.url);
	forwardedUrl.searchParams.delete('t');
	const forwardedRequest = new Request(forwardedUrl.toString(), request);
	const stub = namespace.get(namespace.idFromName(spaceName));
	try {
		return await stub.fetch(forwardedRequest);
	} catch (e) {
		// A SpaceDO failure (e.g. no deployment yet, or a build error in the
		// generated app) must not escape as an unhandled exception — that
		// surfaces in dev as a miniflare "Unexpected end of JSON input" flood.
		// Return a graceful 503 the preview iframe can display instead.
		const message = e instanceof Error ? e.message : String(e);
		return new Response(
			`Preview not ready: ${message}. Deploy the app, then reload.`,
			{ status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
		);
	}
}

export function createPreviewAccessResponse(status: 401 | 403 | 429, title: string, message: string): Response {
	return new Response(
		`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title><style>body{margin:0;font-family:Inter,system-ui,sans-serif;background:#0b1020;color:#e5e7eb;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}.card{max-width:480px;background:#111827;border:1px solid #374151;border-radius:12px;padding:24px;box-shadow:0 20px 45px rgba(0,0,0,.35)}h1{margin:0 0 12px;font-size:24px}p{margin:0;color:#cbd5e1;line-height:1.5}</style></head><body><div class="card"><h1>${title}</h1><p>${message}</p></div></body></html>`,
		{
			status,
			headers: {
				'Content-Type': 'text/html; charset=utf-8',
			},
		},
	);
}

function isWebSocketResponse(response: Response): boolean {
	return response.status === 101 || response.headers.get('Upgrade')?.toLowerCase() === 'websocket';
}

/**
 * Confirm the token's embedded `previewVersion` still matches the app's current
 * value. A visibility toggle bumps `previewVersion` (see AppService), so a token
 * minted while public is rejected after a public->private toggle. Read from the
 * primary DB (no cache) for ~1s revocation. `spaceName === appId`.
 */
async function isPreviewVersionCurrent(
	env: Env,
	spaceName: string,
	claims: SpacePreviewClaims,
): Promise<boolean> {
	const current = (await new AppService(env).getPreviewVersion(spaceName)) ?? 0;
	return current === claims.previewVersion;
}

/**
 * Enforce a per-preview-token rate limit. SpaceDO previews are dispatched
 * outside the Hono chain, so the global API limiter does not apply here.
 * Returns a `429` Response when the limit is exceeded, otherwise `null`.
 * The limiter is keyed by a hash of the token (never the raw token).
 */
async function enforcePreviewRateLimit(
	env: Env,
	token: string,
	request: Request,
): Promise<Response | null> {
	try {
		const config = await getGlobalConfigurableSettings(env);
		const tokenId = await JWTUtils.getInstance(env).hashToken(token);
		await RateLimitService.enforceSpacePreviewRateLimit(env, config.security.rateLimit, tokenId, request);
		return null;
	} catch (error) {
		if (error instanceof RateLimitExceededError) {
			return createPreviewAccessResponse(429, 'Too many requests', 'This preview is receiving too many requests. Please slow down and try again shortly.');
		}
		// Fail open on unexpected limiter errors — do not block a legit preview.
		return null;
	}
}

/**
 * Unified, token-only preview auth. A request is authorized if it carries a
 * valid path/branch-scoped preview cookie, or a valid `?t=` token (which then
 * bootstraps the cookie). No session cookie / DB ownership check (claims-only),
 * but the token's `previewVersion` must still match the app's current epoch.
 */
export async function handleSpacePreview(
	request: Request,
	env: Env,
	spaceName: string,
	branch: string,
): Promise<Response> {
	const url = new URL(request.url);
	const crossSite = isSeparatePreviewDomain(env);
	const secure = url.protocol === 'https:';

	// 1. Existing preview cookie (covers iframe sub-resources / client fetches).
	const cookieToken = readPreviewCookie(request);
	if (cookieToken) {
		const claims = await verifySpacePreviewToken(env, cookieToken, spaceName, branch);
		if (claims && (await isPreviewVersionCurrent(env, spaceName, claims))) {
			const limited = await enforcePreviewRateLimit(env, cookieToken, request);
			if (limited) return limited;
			return forwardToSpacePreview(request, env, spaceName);
		}
	}

	// 2. `?t=` token: forward and bootstrap the cookie (serve-in-place).
	const queryToken = url.searchParams.get('t') ?? '';
	if (queryToken) {
		const claims = await verifySpacePreviewToken(env, queryToken, spaceName, branch);
		if (claims && (await isPreviewVersionCurrent(env, spaceName, claims))) {
			const limited = await enforcePreviewRateLimit(env, queryToken, request);
			if (limited) return limited;
			const response = await forwardToSpacePreview(request, env, spaceName);
			if (isWebSocketResponse(response)) {
				return response;
			}
			const withCookie = new Response(response.body, response);
			withCookie.headers.append(
				'Set-Cookie',
				buildPreviewCookie({ token: queryToken, spaceName, branch, crossSite, secure }),
			);
			return withCookie;
		}
	}

	if (queryToken || cookieToken) {
		return createPreviewAccessResponse(401, 'Access denied', 'This preview link is invalid or has expired.');
	}
	return createPreviewAccessResponse(401, 'Access denied', 'This preview link is missing an access token.');
}