feat(bookings-tryst): Introduce CookieBlobAdapter and TrystSessionManager for cookie-based session handling and persistence

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-21 14:38:52 -07:00
parent b986b0ce60
commit e3734fdc5d
3 changed files with 364 additions and 0 deletions

View file

@ -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<string, unknown>)['cookies'])) {
return ((parsed as Record<string, unknown>)['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<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}

View file

@ -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<TrystSessionResult> {
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<Browser> {
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<boolean> {
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<string | null> {
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<HTMLAnchorElement>(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<string | null> {
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 };
}

View file

@ -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';