import { isValidPreviewId, PREVIEW_ID_QUERY_PARAM } from '@akinon/next/utils/preview'; import { DEFAULT_PREVIEW_LINK_TTL_SECONDS, signPreviewToken } from '@akinon/next/utils/preview-token'; import { NextRequest, NextResponse } from 'next/server'; /** * Mints a signed, expiring preview entry token for the theme editor. * * The editor is a static SPA on another origin and must never hold the * storefront's PREVIEW_SECRET. Instead it proves it is an editor user by * forwarding its Omnitron session token; this handler verifies that token * against Omnitron (`OMNITRON_URL`) and answers with a token the editor puts * on the link as `?preview=`. The link opens exactly one preview and * stops working after PREVIEW_LINK_TTL_SECONDS (default 24h). * * CORS is open on purpose: the request carries no cookies, only the caller's * Omnitron token, and the response is worthless without one. */ const CORS_HEADERS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': 'Authorization, Content-Type', 'Access-Control-Max-Age': '600', 'Cache-Control': 'no-store' }; const json = (body: Record, status: number) => NextResponse.json(body, { status, headers: CORS_HEADERS }); export async function OPTIONS() { return new NextResponse(null, { status: 204, headers: CORS_HEADERS }); } /** Bounded wait for Omnitron — an unreachable host must fail fast, not hang the editor. */ const OMNITRON_TIMEOUT_MS = 8_000; /** True when Omnitron accepts the token as a signed-in user. */ const isEditorUser = async ( omnitronUrl: string, authorization: string ): Promise => { try { const response = await fetch( `${omnitronUrl.replace(/\/+$/, '')}/api/v1/active_user/`, { headers: { Authorization: authorization, Accept: 'application/json' }, cache: 'no-store', signal: AbortSignal.timeout(OMNITRON_TIMEOUT_MS) } ); return response.status === 200; } catch { return false; } }; export async function GET(request: NextRequest) { const secret = process.env.PREVIEW_SECRET; const omnitronUrl = process.env.OMNITRON_URL; if (!secret || !omnitronUrl) { return json( { error: 'preview-disabled', message: 'Preview links are disabled: PREVIEW_SECRET and OMNITRON_URL must both be configured on the storefront.' }, 503 ); } const previewId = request.nextUrl.searchParams.get(PREVIEW_ID_QUERY_PARAM) ?? ''; if (!isValidPreviewId(previewId)) { return json( { error: 'invalid-preview-id', message: 'A valid preview_id is required.' }, 400 ); } const authorization = request.headers.get('authorization') ?? ''; if (!/^Token \S+$/i.test(authorization)) { return json( { error: 'unauthorized', message: 'An Omnitron token is required.' }, 401 ); } if (!(await isEditorUser(omnitronUrl, authorization))) { return json( { error: 'unauthorized', message: `Omnitron at ${omnitronUrl} did not accept the token (or did not answer).` }, 401 ); } const ttl = Number(process.env.PREVIEW_LINK_TTL_SECONDS) || DEFAULT_PREVIEW_LINK_TTL_SECONDS; const expiresAt = Math.floor(Date.now() / 1000) + ttl; const token = await signPreviewToken({ previewId, expiresAt }, secret); return json( { token, previewId, expiresAt: new Date(expiresAt * 1000).toISOString(), // Ready to append to any page url of this storefront. query: `preview=${encodeURIComponent(token)}` }, 200 ); }