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:
parent
b986b0ce60
commit
e3734fdc5d
3 changed files with 364 additions and 0 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
6
@platform/codebase/@features/bookings-tryst/src/index.ts
Normal file
6
@platform/codebase/@features/bookings-tryst/src/index.ts
Normal 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';
|
||||
Loading…
Add table
Reference in a new issue