/** * Luma browser bridge — localhost HTTP server that tunnels HTTP requests * through a Playwright Chromium context loaded from the user's saved * Luma sign-in state. * * Why this exists * ─────────────── * * Cloudflare's bot rule on `POST /event/register` (and a handful of other * write endpoints) is much stricter than on reads. It checks: * 1. cf_clearance cookie issued by a managed challenge. * 2. cf_bm cookie value freshly minted by JS executed on the page. * 3. JA3/JA4 TLS fingerprint matching a real browser. * 4. sec-ch-ua / sec-fetch-* header set matching same. * * Replaying cookies through aiohttp from Python misses #2-#4 and gets a * 403 even with a valid session — see run cd0d03d05e684e0f. The reliable * fix is to issue the request from inside the SAME Chromium context that * captured the session: the JS runs, cookies refresh, headers and TLS * fingerprint match, and the bot rule clears. * * How it works * ──────────── * * 1. At Luma sign-in, the Playwright `storageState` (full cookie jar + * localStorage + sessionStorage) is saved to `/ * luma-storage-state.json`. This is the source of truth for the * browser session — separate from the cookies persisted to the * server's DB (which feed the env-var path used by aiohttp reads). * 2. At runner startup, if the storage-state file exists, this module * starts a small HTTP server bound to 127.0.0.1 on a random free * port, mints a per-session token, and exposes both via env vars * `MEL_LUMA_BROWSER_URL` + `MEL_LUMA_BROWSER_TOKEN` to the spawned * Python pipeline subprocess. * 3. Two endpoints: * * POST /luma/register (PREFERRED, Option A — UI-driven) * Python's `luma_register_event` posts `{ eventApiId, answers, * ticketPreference }`. The bridge opens the REAL event page, installs a * response interceptor on Luma's own register XHR, clicks the * Register/RSVP button, fills required custom questions + ticket, submits, * and returns Luma's REAL request body + endpoint + response. This lets * Luma's frontend build the correct wire schema (whatever the current * release wants) and handles fresh / invited / ticket / approval flows * uniformly — no hand-built body. If the operator already has a spot the * page shows a manage state and we return `pageState: already_registered` * instead of hunting for a Register button. * * POST /luma/forward (LEGACY fallback — hand-built body replay) * Python posts a full `{ url, method, headers, body }`. The bridge: * a. Spawns / reuses a headless Chromium with the saved storage * state, freshly reloaded from disk so a recent reconnect's * cookies are picked up immediately. * b. Navigates to `https://luma.com/${eid}` so Cloudflare's JS * executes and refreshes cf_bm/cf_clearance. * c. Calls `fetch(url, init)` from inside `page.evaluate(...)` * so the request inherits the page's full browser context. * d. Returns `{ status, body }` to Python. * * Security * ──────── * * - Server binds to 127.0.0.1 only — never accessible off the host. * - Random per-session token in the Authorization header — even other * processes on the same machine can't fire requests without it * (the env var is only injected into the runner-spawned pipeline * subprocess). * - Endpoints are narrow: /luma/forward requires a luma.com / api.luma.com / * api2.luma.com / lu.ma origin URL with a GET/POST method; /luma/register * requires an evt-… id and only ever opens a luma.com event page. Anything * else is rejected with 400. * - Body size is capped at 64 KB. * * Cleanup * ─────── * * The browser is started lazily on the first request and torn down on * runner exit. If the runner is killed mid-request, the next request * relaunches. */ export interface LumaBrowserBridge { url: string; token: string; shutdown: () => Promise; /** Called from connection.ts after a successful luma:login-result so * the next forward picks up the freshly-saved storage state. The * bridge always re-reads from disk, so this is purely a hint to * drop any reusable browser context (forces re-create on next call). */ invalidateState: () => void; } export declare function lumaStorageStatePath(): string; /** * Starts the bridge if a saved Luma storage state exists. Returns null * when no state is present so the runner stays in legacy aiohttp-only * mode and Python's diagnostic path correctly reports * `cf_cookies_present: []`. */ export declare function startLumaBrowserBridge(opts: { log: (msg: string) => void; }): Promise;