// Signed-in web capture (shots.capture.auth). Two ways in, applied to every browser context before its first shot: // request { request: { url, method?, headers?, body? } }: one HTTP call per context through the context's own // request API, so the cookies it sets stay in that context. Secrets are references only: // "${cred:NAME}" (.storebound/credentials.json: reviewDemoAccount.username / .password, the same with // REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD from the environment, or captureAuth.NAME) or "${env:NAME}". // A password-like key with a literal value is refused. A non-2xx/3xx status stops the capture with the // status, never the values. // storageState { storageState: ".storebound/auth.json" }: Playwright storage state (cookies + localStorage) saved by // `storebound shots login`, loaded into every context. It must live in .storebound/ (gitignored). // Resolved values are collected so every log line, error and report text can be masked (maskSecrets). import { existsSync, readFileSync } from 'node:fs'; import { join, resolve as resolvePath } from 'node:path'; import { reviewDemoAccount } from '../lib/credentials'; export interface AuthRequest { /** Absolute URL, or a path after shots.capture.baseUrl (e.g. "/api/login"). */ url: string; /** Default POST. */ method?: string; headers?: Record; /** An object is sent as JSON, a string as is. Secrets as "${cred:NAME}" / "${env:NAME}" only. */ body?: Record | string; } export interface CaptureAuth { request?: AuthRequest; /** Playwright storage state file in .storebound/ (from `storebound shots login`). */ storageState?: string; /** Pattern (regex source) of the login page URL; a shot that lands there warns. Default /login|signin|sign-in|auth/. */ loginUrl?: string; } export const DEFAULT_STATE = '.storebound/auth.json'; const REF = /\$\{(cred|env):([A-Za-z0-9_.-]+)\}/g; const SECRET_KEY = /pass(word)?|pwd|secret|token|api[-_]?key|authorization|cookie|session|otp|pin$|credential/i; /** Problems with shots.capture.auth (pure): shape, a gitignored state path, and literal secrets. */ export function validateAuth(a: CaptureAuth | undefined): string[] { if (!a) return []; const e: string[] = []; if (!!a.request === !!a.storageState) e.push('shots.capture.auth: either { request: { url, method, headers, body } } or { storageState: ".storebound/auth.json" }'); if (a.storageState && !/^\.storebound[\\/]/.test(a.storageState)) e.push('shots.capture.auth.storageState: a file in .storebound/ (gitignored), e.g. ".storebound/auth.json"'); if (a.request) { if (!a.request.url) e.push('shots.capture.auth.request.url: the sign-in endpoint (absolute, or a path after baseUrl)'); for (const k of literalSecrets(a.request)) e.push(`shots.capture.auth.request.${k}: a secret is never written in the config; use "\${cred:NAME}" (.storebound/credentials.json) or "\${env:NAME}"`); } if (a.loginUrl) try { new RegExp(a.loginUrl); } catch { e.push('shots.capture.auth.loginUrl: a regular expression'); } return e; } /** Paths (headers.X, body.y) whose key looks like a secret but whose value is not only references. */ export function literalSecrets(r: AuthRequest): string[] { const out: string[] = []; const literal = (v: unknown): boolean => typeof v === 'string' ? v.replace(REF, '').replace(/^(Bearer|Basic|Token)\s+/i, '').trim() !== '' : v !== undefined && v !== null && typeof v !== 'object'; const walk = (o: unknown, path: string): void => { if (!o || typeof o !== 'object') return; for (const [k, v] of Object.entries(o as Record)) { if (v && typeof v === 'object') walk(v, `${path}.${k}`); else if (SECRET_KEY.test(k) && literal(v)) out.push(`${path}.${k}`); } }; walk(r.headers, 'headers'); if (typeof r.body === 'string') { // a form or JSON string body: check its fields the same way let parsed: unknown = null; try { parsed = JSON.parse(r.body); } catch { if (r.body.includes('=')) parsed = Object.fromEntries(new URLSearchParams(r.body)); } walk(parsed, 'body'); } else walk(r.body, 'body'); return out; } /** Where references resolve: credentials.json (reviewDemoAccount, captureAuth) and the environment. */ export function credLookup(root: string, env: Record = process.env): (kind: 'cred' | 'env', name: string) => string | undefined { let store: Record | null = null; const file = (): Record => { if (store) return store; const p = join(root, '.storebound', 'credentials.json'); try { store = existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) as Record : {}; } catch { store = {}; } return store; }; return (kind, name) => { if (kind === 'env') return env[name] || undefined; if (name === 'reviewDemoAccount.username' || name === 'reviewDemoAccount.password') { const eu = env.REVIEW_DEMO_USER, ep = env.REVIEW_DEMO_PASSWORD; const d = eu && ep ? { username: eu, password: ep } : reviewDemoAccount(root); return d ? (name.endsWith('username') ? d.username : d.password) : undefined; } const ca = file().captureAuth as Record | undefined; const v = ca?.[name.replace(/^captureAuth\./, '')]; return typeof v === 'string' && v ? v : undefined; }; } /** Replaces every reference; collects the values into `secrets`. A missing one throws with its NAME only. */ export function resolveRefs(text: string, lookup: ReturnType, secrets: Set): string { return text.replace(REF, (_m, kind: 'cred' | 'env', name: string) => { const v = lookup(kind, name); if (v === undefined) throw new Error(kind === 'env' ? `shots.capture.auth: environment variable ${name} is not set` : `shots.capture.auth: \${cred:${name}} is not in .storebound/credentials.json (reviewDemoAccount { username, password } or captureAuth { "${name.replace(/^captureAuth\./, '')}": ... })`); secrets.add(v); return v; }); } const deepResolve = (v: unknown, f: (s: string) => string): unknown => typeof v === 'string' ? f(v) : Array.isArray(v) ? v.map(x => deepResolve(x, f)) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, deepResolve(x, f)])) : v; export interface ResolvedRequest { url: string; method: string; headers: Record; body?: string } /** The sign-in request with references resolved (values only in memory, listed in `secrets`). */ export function resolveRequest(r: AuthRequest, baseUrl: string, lookup: ReturnType, secrets: Set): ResolvedRequest { const f = (s: string) => resolveRefs(s, lookup, secrets); const headers = deepResolve(r.headers ?? {}, f) as Record; let body: string | undefined; if (typeof r.body === 'string') body = f(r.body); else if (r.body) { body = JSON.stringify(deepResolve(r.body, f)); if (!Object.keys(headers).some(h => h.toLowerCase() === 'content-type')) headers['content-type'] = 'application/json'; } const url = /^https?:\/\//.test(r.url) ? f(r.url) : baseUrl.replace(/\/$/, '') + f(r.url); return { url, method: (r.method ?? 'POST').toUpperCase(), headers, ...(body !== undefined ? { body } : {}) }; } /** Hides every resolved secret (and its URL-encoded form) in a text. */ export function maskSecrets(text: string, secrets: Iterable): string { let out = text; for (const s of secrets) if (s.length >= 3) for (const form of new Set([s, encodeURIComponent(s)])) out = out.split(form).join('***'); return out; } /** URL for a log line: origin + path only (a query can carry a token). */ export const safeUrl = (u: string): string => { try { const x = new URL(u); return x.origin + x.pathname; } catch { return u.split('?')[0]!; } }; /** A shot that wanted `want` but shows a login page. */ export function landedOnLogin(want: string, got: string, pattern?: string): boolean { const re = new RegExp(pattern ?? '(^|[/._-])(login|log-in|signin|sign-in|sign_in|auth)([/._?-]|$)', 'i'); try { const w = new URL(want), g = new URL(got); if (w.pathname === g.pathname) return false; return re.test(g.pathname + g.search) && !re.test(w.pathname); } catch { return false; } } export const expiredHint = (url: string): string => `${safeUrl(url)} is a sign-in page: session expired? run storebound shots login again`; /* eslint-disable @typescript-eslint/no-explicit-any */ export interface AuthPlan { /** Extra newContext options (storageState). */ contextOptions: Record; /** Signs a fresh context in (request auth); a no-op for storageState. */ prepare: (context: any) => Promise; secrets: Set; loginUrl?: string; } /** Builds the per-context sign-in from shots.capture.auth. Throws before any browser starts when something is missing. */ export function authPlan(a: CaptureAuth, o: { root: string; baseUrl: string; env?: Record }): AuthPlan { const problems = validateAuth(a); if (problems.length) throw new Error(problems.join('\n ')); const secrets = new Set(); if (a.storageState) { const p = resolvePath(o.root, a.storageState); if (!existsSync(p)) throw new Error(`shots.capture.auth.storageState: ${a.storageState} does not exist; run storebound shots login (sign in to a demo account by hand)`); let state: { cookies?: { value?: string }[] }; try { state = JSON.parse(readFileSync(p, 'utf8')) as typeof state; } catch { throw new Error(`shots.capture.auth.storageState: ${a.storageState} is not a Playwright storage state; run storebound shots login again`); } for (const c of state.cookies ?? []) if (c.value && c.value.length >= 6) secrets.add(c.value); return { contextOptions: { storageState: p }, prepare: async () => {}, secrets, ...(a.loginUrl ? { loginUrl: a.loginUrl } : {}) }; } const req = resolveRequest(a.request!, o.baseUrl, credLookup(o.root, o.env), secrets); return { contextOptions: {}, secrets, ...(a.loginUrl ? { loginUrl: a.loginUrl } : {}), prepare: async (context: any) => { let status = 0; try { const r = await context.request.fetch(req.url, { method: req.method, headers: req.headers, ...(req.body !== undefined ? { data: req.body } : {}), timeout: 30_000, maxRedirects: 0 }); status = r.status(); await r.dispose?.(); } catch (e) { throw new Error(`sign-in request ${req.method} ${safeUrl(maskSecrets(req.url, secrets))} failed: ${maskSecrets((e as Error).message.split('\n')[0]!, secrets).slice(0, 200)}`); } if (status < 200 || status >= 400) throw new Error(`sign-in request ${req.method} ${safeUrl(maskSecrets(req.url, secrets))} returned HTTP ${status} (check the demo account in .storebound/credentials.json and shots.capture.auth.request; values not shown)`); // the session cookies it set are masked like the request values const cookies = await context.cookies(); for (const c of cookies) if (c.value && c.value.length >= 6) secrets.add(c.value); }, }; } /* eslint-enable @typescript-eslint/no-explicit-any */