From e3734fdc5d7cc93e77f1cefb4118da2fbcf4a9d3 Mon Sep 17 00:00:00 2001 From: autocommit Date: Thu, 21 May 2026 14:38:52 -0700 Subject: [PATCH] =?UTF-8?q?feat(bookings-tryst):=20=E2=9C=A8=20Introduce?= =?UTF-8?q?=20CookieBlobAdapter=20and=20TrystSessionManager=20for=20cookie?= =?UTF-8?q?-based=20session=20handling=20and=20persistence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../bookings-tryst/src/adapter/cookie-blob.ts | 132 ++++++++++ .../src/adapter/tryst-session.ts | 226 ++++++++++++++++++ .../@features/bookings-tryst/src/index.ts | 6 + 3 files changed, 364 insertions(+) create mode 100644 @platform/codebase/@features/bookings-tryst/src/adapter/cookie-blob.ts create mode 100644 @platform/codebase/@features/bookings-tryst/src/adapter/tryst-session.ts create mode 100644 @platform/codebase/@features/bookings-tryst/src/index.ts diff --git a/@platform/codebase/@features/bookings-tryst/src/adapter/cookie-blob.ts b/@platform/codebase/@features/bookings-tryst/src/adapter/cookie-blob.ts new file mode 100644 index 0000000..df8b6fa --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/src/adapter/cookie-blob.ts @@ -0,0 +1,132 @@ +/** + * Cookie-blob parsing for the Tryst cookie auth path. + * + * The vault stores a credential's `cookie_blob` as opaque text. This module is + * the single place that decides what that text means: it accepts the three + * shapes a human or browser realistically produces and normalises them to the + * cookie objects Playwright's `BrowserContext.addCookies(...)` expects. + * + * Accepted inputs: + * 1. A `Cookie:` request-header string — `_tryst_session=abc; other=def` + * (optionally prefixed with `Cookie:`). This is what DevTools → Network → + * a request's "Cookie" header yields, and it includes HttpOnly cookies. + * 2. A JSON array of cookie objects — `[{ "name": ..., "value": ..., ... }]`. + * 3. A Playwright `storageState` JSON — `{ "cookies": [ ... ] }`. + */ + +export interface TrystCookie { + name: string; + value: string; + domain: string; + path: string; + secure: boolean; + sameSite: 'Strict' | 'Lax' | 'None'; +} + +const DEFAULT_DOMAIN = '.tryst.link'; +const DEFAULT_PATH = '/'; + +/** Parse any of the accepted cookie-blob shapes into Playwright-ready cookies. */ +export function parseCookieBlob(raw: string): TrystCookie[] { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error('cookie blob is empty'); + } + + const cookies = + trimmed.startsWith('[') || trimmed.startsWith('{') + ? fromJson(trimmed) + : fromHeaderString(trimmed); + + if (cookies.length === 0) { + throw new Error('cookie blob parsed but contained no cookies'); + } + return cookies; +} + +/** Names only — safe to log. Cookie values are secrets and never returned here. */ +export function cookieNames(cookies: TrystCookie[]): string[] { + return cookies.map((c) => c.name); +} + +function fromJson(raw: string): TrystCookie[] { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error( + `cookie blob looks like JSON but failed to parse: ${(err as Error).message}`, + ); + } + + // Playwright storageState — { cookies: [...] } + if (isRecord(parsed) && Array.isArray((parsed as Record)['cookies'])) { + return ((parsed as Record)['cookies'] as unknown[]).map(toCookie); + } + // Array of cookie objects + if (Array.isArray(parsed)) { + return parsed.map(toCookie); + } + // Plain { name: value } map + if (isRecord(parsed)) { + return Object.entries(parsed).map(([name, value]) => + normalise({ name, value: String(value) }), + ); + } + throw new Error('cookie blob JSON is not an array, storageState, or name/value map'); +} + +function fromHeaderString(raw: string): TrystCookie[] { + const body = raw.replace(/^cookie:\s*/i, ''); + return body + .split(';') + .map((pair) => pair.trim()) + .filter((pair) => pair.length > 0) + .map((pair) => { + const eq = pair.indexOf('='); + if (eq <= 0) { + throw new Error(`malformed cookie pair (expected name=value): "${pair}"`); + } + return normalise({ name: pair.slice(0, eq).trim(), value: pair.slice(eq + 1).trim() }); + }); +} + +function toCookie(item: unknown): TrystCookie { + if (!isRecord(item)) { + throw new Error('cookie entry is not an object'); + } + const name = item['name']; + const value = item['value']; + if (typeof name !== 'string' || name.length === 0) { + throw new Error('cookie entry is missing a non-empty "name"'); + } + if (typeof value !== 'string') { + throw new Error(`cookie "${name}" is missing a string "value"`); + } + return normalise({ + name, + value, + domain: typeof item['domain'] === 'string' ? (item['domain'] as string) : undefined, + path: typeof item['path'] === 'string' ? (item['path'] as string) : undefined, + }); +} + +function normalise(input: { + name: string; + value: string; + domain?: string; + path?: string; +}): TrystCookie { + return { + name: input.name, + value: input.value, + domain: input.domain && input.domain.length > 0 ? input.domain : DEFAULT_DOMAIN, + path: input.path && input.path.length > 0 ? input.path : DEFAULT_PATH, + secure: true, + sameSite: 'Lax', + }; +} + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} diff --git a/@platform/codebase/@features/bookings-tryst/src/adapter/tryst-session.ts b/@platform/codebase/@features/bookings-tryst/src/adapter/tryst-session.ts new file mode 100644 index 0000000..84a52d2 --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/src/adapter/tryst-session.ts @@ -0,0 +1,226 @@ +/** + * Tryst cookie-mode session verification. + * + * Loads stored cookies into a stealth Chromium context, navigates to the Tryst + * advertiser app, and reports whether the cookies constitute a live logged-in + * session. This is the cookie path of the dual-mode auth described in + * `_engineering-surface-adapter-container.md` §Layer 4 — no login form, no + * captcha: the cookie either authenticates or it does not. + * + * Logged-in signal: Tryst is a Rails app. An unauthenticated request to the app + * root (`app.tryst.link/`) 302-redirects to `/log_in`. An authenticated request + * is served the dashboard. So: navigate to the app root — if the final URL is + * still `/log_in` (or a login form is on the page), the session is not live. + */ + +import type { Browser, Page } from 'playwright'; + +import type { TrystCookie } from './cookie-blob.js'; + +const APP_ROOT = 'https://app.tryst.link/'; +const LOGIN_PATH = '/log_in'; + +/** Chrome-on-macOS. Chromium + the stealth plugin present as Chrome; keep the + * UA consistent with that. Override to match the device the cookie came from. */ +const DEFAULT_USER_AGENT = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'; + +export interface VerifyOptions { + /** Browser UA. Default: Chrome-on-macOS. Match the cookie's origin device. */ + userAgent?: string; + /** Run headful for debugging. Default: true (headless). */ + headless?: boolean; + /** Navigation timeout, ms. Default: 30000. */ + timeoutMs?: number; + /** Optional outbound proxy, e.g. a Tor SOCKS endpoint. */ + proxy?: { server: string }; + /** If set, a full-page screenshot of the final state is written here. */ + screenshotPath?: string; +} + +export interface TrystSessionResult { + /** True iff the cookies produced a live authenticated session. */ + loggedIn: boolean; + /** URL the app root settled on after any redirects. */ + finalUrl: string; + /** Title of the settled page. */ + pageTitle: string; + /** Best-effort account handle/name; null if not extractable. */ + handle: string | null; + /** Human-readable explanation of the verdict. */ + reason: string; + /** Set on a hard failure (navigation error, anti-bot wall). */ + error: string | null; + /** Path of the screenshot written, if `screenshotPath` was given. */ + screenshotPath: string | null; +} + +/** Verify a Tryst session from stored cookies. Never throws — failures are + * reported in the result so the caller gets a verdict either way. */ +export async function verifyTrystSession( + cookies: TrystCookie[], + opts: VerifyOptions = {}, +): Promise { + const timeout = opts.timeoutMs ?? 30_000; + let browser: Browser | undefined; + + try { + browser = await launchStealthChromium(opts); + const context = await browser.newContext({ + userAgent: opts.userAgent ?? DEFAULT_USER_AGENT, + viewport: { width: 1440, height: 900 }, + locale: 'en-US', + timezoneId: 'America/Los_Angeles', + }); + await context.addCookies(cookies); + const page = await context.newPage(); + page.setDefaultTimeout(timeout); + + let navError: string | null = null; + try { + await page.goto(APP_ROOT, { waitUntil: 'domcontentloaded', timeout }); + // Allow a client-side or meta redirect to settle. + await page.waitForTimeout(1_500); + } catch (err) { + navError = `navigation failed: ${(err as Error).message}`; + } + + const finalUrl = page.url(); + const pageTitle = await page.title().catch(() => ''); + const screenshotPath = await captureScreenshot(page, opts.screenshotPath); + + if (navError) { + return result(false, finalUrl, pageTitle, null, navError, navError, screenshotPath); + } + + const antiBot = detectAntiBotWall(pageTitle); + if (antiBot) { + return result(false, finalUrl, pageTitle, null, antiBot, antiBot, screenshotPath); + } + + const onLoginPage = + new URL(finalUrl).pathname.startsWith(LOGIN_PATH) || (await hasLoginForm(page)); + + if (onLoginPage) { + return result( + false, + finalUrl, + pageTitle, + null, + 'app root redirected to the login page — the cookies are not a live session ' + + '(expired, never valid, or Tryst bound the session to another IP/device)', + null, + screenshotPath, + ); + } + + const handle = await extractHandle(page); + return result( + true, + finalUrl, + pageTitle, + handle, + `app root served the authenticated app (${finalUrl}) without redirecting to login`, + null, + screenshotPath, + ); + } catch (err) { + return result( + false, + '', + '', + null, + `verification crashed: ${(err as Error).message}`, + (err as Error).message, + null, + ); + } finally { + await browser?.close().catch(() => undefined); + } +} + +async function launchStealthChromium(opts: VerifyOptions): Promise { + const { chromium } = await import('playwright'); + const { addExtra } = await import('playwright-extra'); + const stealthMod = await import('puppeteer-extra-plugin-stealth'); + const StealthPlugin = stealthMod.default ?? stealthMod; + + const stealthChromium = addExtra(chromium); + stealthChromium.use(typeof StealthPlugin === 'function' ? StealthPlugin() : StealthPlugin); + + return stealthChromium.launch({ + headless: opts.headless ?? true, + args: [ + '--no-sandbox', + '--disable-blink-features=AutomationControlled', + '--disable-dev-shm-usage', + ], + ...(opts.proxy ? { proxy: opts.proxy } : {}), + }); +} + +/** Cloudflare / generic interstitial detection by page title. */ +function detectAntiBotWall(title: string): string | null { + const t = title.toLowerCase(); + if (t.includes('just a moment') || t.includes('attention required')) { + return `hit an anti-bot interstitial ("${title}") — needs a cleaner IP or the cf_clearance cookie`; + } + return null; +} + +async function hasLoginForm(page: Page): Promise { + return page + .locator('form#new_session, form[action*="log_in"], input#password[type="password"]') + .first() + .count() + .then((n) => n > 0) + .catch(() => false); +} + +/** Best-effort account identity. Absence does not change the logged-in verdict. */ +async function extractHandle(page: Page): Promise { + return page + .evaluate(() => { + const pick = (sel: string): string | null => { + const el = document.querySelector(sel); + const text = el?.textContent?.trim(); + return text && text.length > 0 && text.length < 80 ? text : null; + }; + const href = (sel: string): string | null => { + const el = document.querySelector(sel); + return el?.getAttribute('href') ?? null; + }; + return ( + pick('[data-test*="account"], [class*="account-name"], [class*="username"]') ?? + href('a[href*="/escort/"], a[href*="/account"]') ?? + null + ); + }) + .catch(() => null); +} + +async function captureScreenshot( + page: Page, + path: string | undefined, +): Promise { + if (!path) return null; + try { + await page.screenshot({ path, fullPage: true }); + return path; + } catch { + return null; + } +} + +function result( + loggedIn: boolean, + finalUrl: string, + pageTitle: string, + handle: string | null, + reason: string, + error: string | null, + screenshotPath: string | null, +): TrystSessionResult { + return { loggedIn, finalUrl, pageTitle, handle, reason, error, screenshotPath }; +} diff --git a/@platform/codebase/@features/bookings-tryst/src/index.ts b/@platform/codebase/@features/bookings-tryst/src/index.ts new file mode 100644 index 0000000..a54be85 --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/src/index.ts @@ -0,0 +1,6 @@ +export { parseCookieBlob, cookieNames, type TrystCookie } from './adapter/cookie-blob.js'; +export { + verifyTrystSession, + type VerifyOptions, + type TrystSessionResult, +} from './adapter/tryst-session.js';