From ba52d72034c979a7fb4b2908784f02af0f20fe31 Mon Sep 17 00:00:00 2001 From: artickc Date: Sun, 26 Jul 2026 04:39:00 +0300 Subject: [PATCH] fix(adobe-firefly): durable session, off-screen Chrome recovery, browser sign-in Rebuild x-arp-session-id from Cookie pieces (sid/ark/forter) so aux_sid is never sent as ARP. Sticky ARP + submit spacing reduce mid-batch colligo 408 thrash. Add optional managed Chrome warm (off-screen headed by default; Forter rejects headless) and POST /api/providers/{id}/login browser sign-in that returns JWT+Cookie after a fresh SSO. Visible sign-in resets off-screen window placement and clears prior Adobe session when adding another account. --- .../imageGeneration/providers/adobeFirefly.ts | 319 +- .../videoGeneration/adobeFireflyHandler.ts | 1 + .../services/adobeFireflyChromeRuntime.ts | 1179 ++++ open-sse/services/adobeFireflyClient.ts | 5318 +++++++++-------- open-sse/services/adobeFireflySession.ts | 1443 +++-- src/app/api/providers/[id]/login/route.ts | 79 + tests/unit/adobe-firefly.test.ts | 90 + 7 files changed, 4987 insertions(+), 3442 deletions(-) create mode 100644 open-sse/services/adobeFireflyChromeRuntime.ts diff --git a/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts b/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts index 8be218668c..d419f22c5a 100644 --- a/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts +++ b/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts @@ -1,159 +1,160 @@ -// Adobe Firefly (unofficial) image-generation handler. -// Family: adobe-firefly-image | Provider: adobe-firefly -// -// Credentials: IMS access_token (JWT, client_id clio-playground-web) or full -// Cookie header from firefly.adobe.com. Cookie → IMS check/v6/token with -// client_id clio-playground-web (Express projectx_webapp fallback). -// -// Reference images (Media page / OpenAI edit aliases): -// 1) POST raw bytes → firefly-3p /v2/storage/image → { images:[{ id }] } -// 2) generate-async with referenceBlobs:[{ id, usage:"general"|"subject" }] -// See browser network capture for live captures. - -import { sanitizeErrorMessage } from "../../../utils/error.ts"; -import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts"; -import { - AdobeFireflyError, - adobeFireflyGenerateImage, - resolveAdobeSourceImageIds, - resolveAdobeImageModel, -} from "../../../services/adobeFireflyClient.ts"; -import { ensureAdobeFireflySession } from "../../../services/adobeFireflySession.ts"; - -function normalizePositiveNumber(value: unknown, fallback: number): number { - const n = Number(value); - return Number.isFinite(n) && n > 0 ? n : fallback; -} - -export async function handleAdobeFireflyImageGeneration({ - model, - provider, - body, - credentials, - log, - fetchImpl = fetch, -}: { - model: string; - provider: string; - providerConfig?: { baseUrl?: string }; - body: { - prompt?: unknown; - size?: unknown; - aspect_ratio?: unknown; - aspectRatio?: unknown; - quality?: unknown; - seed?: unknown; - negative_prompt?: unknown; - timeout_ms?: unknown; - image?: unknown; - image_url?: unknown; - image_urls?: unknown; - images?: unknown; - [key: string]: unknown; - }; - credentials: { apiKey?: string; accessToken?: string }; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; - fetchImpl?: typeof fetch; -}) { - const startTime = Date.now(); - const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; - if (!prompt) { - return saveImageErrorResult({ - provider, - model, - status: 400, - startTime, - error: "Prompt is required for Adobe Firefly image generation", - }); - } - - try { - // Durable session: JWT + Cookie once → auto-rebuild ARP from forter/arkose, - // cache, optional Playwright warm-up. Submit path rotates ARP on 408. - const session = await ensureAdobeFireflySession({ - credentials, - fetchImpl, - log, - }); - const accessToken = session.accessToken; - const sessionCookie = session.cookie || undefined; - const arpSessionId = session.arpSessionId; - const timeoutMs = normalizePositiveNumber(body.timeout_ms, 180_000); - const seed = - typeof body.seed === "number" - ? body.seed - : typeof body.seed === "string" && body.seed.trim() - ? Number(body.seed) - : undefined; - - // Cap uploads by model family (matches MediaViewModel GetSourceImageLimit). - const { id: resolvedId } = resolveAdobeImageModel(model); - const maxRefs = - resolvedId.includes("nano-banana") || resolvedId.includes("gpt-image") - ? 4 - : 2; - - const sourceImageIds = await resolveAdobeSourceImageIds({ - accessToken, - body, - max: maxRefs, - sessionCookie, - arpSessionId, - prompt, - fetchImpl, - log, - }); - - log?.info?.( - "IMAGE", - `${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` + - (sourceImageIds.length ? ` | refs: ${sourceImageIds.length}` : "") + - ` | session=${session.source}` - ); - - const result = await adobeFireflyGenerateImage({ - accessToken, - prompt, - model, - size: body.size, - aspectRatio: body.aspect_ratio ?? body.aspectRatio ?? body.size, - quality: body.quality, - seed: Number.isFinite(seed as number) ? (seed as number) : undefined, - negativePrompt: - typeof body.negative_prompt === "string" ? body.negative_prompt : undefined, - sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined, - sessionCookie, - arpSessionId, - timeoutMs, - fetchImpl, - log, - }); - - return saveImageSuccessResult({ - provider, - model, - startTime, - images: [{ url: result.url }], - }); - } catch (err) { - if (err instanceof AdobeFireflyError) { - log?.error?.("IMAGE", `${provider} adobe-firefly error ${err.status}: ${err.message}`); - return saveImageErrorResult({ - provider, - model, - status: err.status, - startTime, - error: err.message, - }); - } - const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); - log?.error?.("IMAGE", `${provider} adobe-firefly exception: ${errorText}`); - return saveImageErrorResult({ - provider, - model, - status: 500, - startTime, - error: errorText, - }); - } -} +// Adobe Firefly (unofficial) image-generation handler. +// Family: adobe-firefly-image | Provider: adobe-firefly +// +// Credentials: IMS access_token (JWT, client_id clio-playground-web) or full +// Cookie header from firefly.adobe.com. Cookie → IMS check/v6/token with +// client_id clio-playground-web (Express projectx_webapp fallback). +// +// Reference images (Media page / OpenAI edit aliases): +// 1) POST raw bytes → firefly-3p /v2/storage/image → { images:[{ id }] } +// 2) generate-async with referenceBlobs:[{ id, usage:"general"|"subject" }] +// See web_providers/adobe_atach_images.txt for live captures. + +import { sanitizeErrorMessage } from "../../../utils/error.ts"; +import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts"; +import { + AdobeFireflyError, + adobeFireflyGenerateImage, + resolveAdobeSourceImageIds, + resolveAdobeImageModel, +} from "../../../services/adobeFireflyClient.ts"; +import { ensureAdobeFireflySession } from "../../../services/adobeFireflySession.ts"; + +function normalizePositiveNumber(value: unknown, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +export async function handleAdobeFireflyImageGeneration({ + model, + provider, + body, + credentials, + log, + fetchImpl = fetch, +}: { + model: string; + provider: string; + providerConfig?: { baseUrl?: string }; + body: { + prompt?: unknown; + size?: unknown; + aspect_ratio?: unknown; + aspectRatio?: unknown; + quality?: unknown; + seed?: unknown; + negative_prompt?: unknown; + timeout_ms?: unknown; + image?: unknown; + image_url?: unknown; + image_urls?: unknown; + images?: unknown; + [key: string]: unknown; + }; + credentials: { apiKey?: string; accessToken?: string }; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; + fetchImpl?: typeof fetch; +}) { + const startTime = Date.now(); + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if (!prompt) { + return saveImageErrorResult({ + provider, + model, + status: 400, + startTime, + error: "Prompt is required for Adobe Firefly image generation", + }); + } + + try { + // Durable session: JWT + Cookie once → auto-rebuild ARP from forter/arkose, + // cache, optional Playwright warm-up. Submit path rotates ARP on 408. + const session = await ensureAdobeFireflySession({ + credentials, + fetchImpl, + log, + }); + const accessToken = session.accessToken; + const sessionCookie = session.cookie || undefined; + const arpSessionId = session.arpSessionId; + const timeoutMs = normalizePositiveNumber(body.timeout_ms, 180_000); + const seed = + typeof body.seed === "number" + ? body.seed + : typeof body.seed === "string" && body.seed.trim() + ? Number(body.seed) + : undefined; + + // Cap uploads by model family (matches MediaViewModel GetSourceImageLimit). + const { id: resolvedId } = resolveAdobeImageModel(model); + const maxRefs = + resolvedId.includes("nano-banana") || resolvedId.includes("gpt-image") + ? 4 + : 2; + + const sourceImageIds = await resolveAdobeSourceImageIds({ + accessToken, + body, + max: maxRefs, + sessionCookie, + arpSessionId, + prompt, + fetchImpl, + log, + }); + + log?.info?.( + "IMAGE", + `${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` + + (sourceImageIds.length ? ` | refs: ${sourceImageIds.length}` : "") + + ` | session=${session.source}` + ); + + const result = await adobeFireflyGenerateImage({ + accessToken, + prompt, + model, + size: body.size, + aspectRatio: body.aspect_ratio ?? body.aspectRatio ?? body.size, + quality: body.quality, + seed: Number.isFinite(seed as number) ? (seed as number) : undefined, + negativePrompt: + typeof body.negative_prompt === "string" ? body.negative_prompt : undefined, + sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined, + sessionCookie, + arpSessionId, + sessionFingerprint: session.fingerprint, + timeoutMs, + fetchImpl, + log, + }); + + return saveImageSuccessResult({ + provider, + model, + startTime, + images: [{ url: result.url }], + }); + } catch (err) { + if (err instanceof AdobeFireflyError) { + log?.error?.("IMAGE", `${provider} adobe-firefly error ${err.status}: ${err.message}`); + return saveImageErrorResult({ + provider, + model, + status: err.status, + startTime, + error: err.message, + }); + } + const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + log?.error?.("IMAGE", `${provider} adobe-firefly exception: ${errorText}`); + return saveImageErrorResult({ + provider, + model, + status: 500, + startTime, + error: errorText, + }); + } +} diff --git a/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts b/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts index 00a4dddbd0..77e0c7e161 100644 --- a/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts +++ b/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts @@ -103,6 +103,7 @@ export async function handleAdobeFireflyVideoGeneration({ sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined, sessionCookie, arpSessionId, + sessionFingerprint: session.fingerprint, timeoutMs, fetchImpl, log, diff --git a/open-sse/services/adobeFireflyChromeRuntime.ts b/open-sse/services/adobeFireflyChromeRuntime.ts new file mode 100644 index 0000000000..2b727852ed --- /dev/null +++ b/open-sse/services/adobeFireflyChromeRuntime.ts @@ -0,0 +1,1179 @@ +/** + * Adobe Firefly optional Chrome (CDP) session runtime. + * + * Default product path is the same as other OmniRoute web-cookie providers + * (notion-web, perplexity-web, …): pure HTTP with the pasted Cookie/JWT — NO browser. + * + * Browser warm is OPT-IN for proactive use (`ADOBE_FIREFLY_BROWSER_REFRESH=1`) and may + * also run mid-batch 408 recovery via `allowWithoutEnvOptIn`. + * + * **Mode (critical for colligo):** Forter risk scores reject Chrome `--headless=new`. + * Live verification: headless warm → still 408; off-screen **headed** Chrome → 200. + * Default is therefore **off-screen headed** (window parked at -32000,-32000 — not visible + * on a normal desktop). Opt into true headless only with `ADOBE_FIREFLY_CHROME_HEADLESS=1` + * (known-broken for generate). Debug on-screen: `ADOBE_FIREFLY_CHROME_VISIBLE=1`. + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + buildAdobeArpSessionIdFromCookies, + extractAdobeForterTimestampMs, + mergeAdobeCookieHeaders, + type AdobeFireflySession, +} from "./adobeFireflySession.ts"; +import { + extractAdobeCookieHeader, + isAdobeUserAccessToken, + looksLikeAdobeJwt, + decodeAdobeJwtPayload, +} from "./adobeFireflyClient.ts"; + +const DEFAULT_CDP_PORT = Number(process.env.ADOBE_FIREFLY_CHROME_CDP_PORT || 9334); +const PROFILE_DIR_NAME = "adobe-chrome-profile"; + +type Log = { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void }; + +type RuntimeState = { + port: number; + profileDir: string; + chromeProc: ChildProcess | null; + browser: import("playwright").Browser | null; + context: import("playwright").BrowserContext | null; + page: import("playwright").Page | null; + lastWarmAt: number; + lastCookieSeed: string; + /** "offscreen" | "visible" | "headless" */ + mode: string; +}; + +let runtime: RuntimeState | null = null; +let warmChain: Promise = Promise.resolve(); +let startingChrome: Promise | null = null; +/** Temporary mode override (e.g. force a visible window for interactive sign-in). */ +let modeOverride: "offscreen" | "visible" | "headless" | null = null; + +/** Prefer off-screen headed (Forter works). Headless is opt-in and usually rejected. */ +function resolveChromeMode(): "offscreen" | "visible" | "headless" { + if (modeOverride) return modeOverride; + if (process.env.ADOBE_FIREFLY_CHROME_HEADLESS === "1") return "headless"; + // Legacy alias: ADOBE_FIREFLY_CHROME_HEADED=1 meant "show window" + if ( + process.env.ADOBE_FIREFLY_CHROME_VISIBLE === "1" || + process.env.ADOBE_FIREFLY_CHROME_HEADED === "1" + ) { + return "visible"; + } + return "offscreen"; +} + +async function safePageWait(page: import("playwright").Page, ms: number): Promise { + try { + if (page.isClosed()) return; + await page.waitForTimeout(ms); + } catch { + /* page closed / target destroyed — caller will re-acquire */ + } +} + +async function ensureLivePage( + context: import("playwright").BrowserContext, + preferred: import("playwright").Page | null +): Promise { + if (preferred && !preferred.isClosed()) { + try { + // Touch the page; if target is dead this throws + void preferred.url(); + return preferred; + } catch { + /* fall through */ + } + } + const existing = + context.pages().find((p) => !p.isClosed() && /firefly\.adobe\.com/i.test(p.url())) || + context.pages().find((p) => !p.isClosed()); + if (existing) return existing; + return context.newPage(); +} + +function dataDir(): string { + return ( + String(process.env.DATA_DIR || process.env.OMNIROUTE_DATA_DIR || "").trim() || + join(process.cwd(), ".data") + ); +} + +function profileDir(): string { + // Prefer LOCALAPPDATA when present so the managed Chrome profile survives restarts. + const local = + process.env.LOCALAPPDATA || + process.env.HOME || + process.env.USERPROFILE || + ""; + if (local) { + const p = join(local, "OmniRoute", PROFILE_DIR_NAME); + try { + mkdirSync(p, { recursive: true }); + } catch { + /* ignore */ + } + return p; + } + const p = join(dataDir(), PROFILE_DIR_NAME); + try { + mkdirSync(p, { recursive: true }); + } catch { + /* ignore */ + } + return p; +} + +function findChromeExecutable(): string | null { + if (process.env.CHROME_PATH && existsSync(process.env.CHROME_PATH)) { + return process.env.CHROME_PATH; + } + const candidates = [ + "C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe", + "C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe", + join(process.env.LOCALAPPDATA || "", "Google", "Chrome", "Application", "chrome.exe"), + "/usr/bin/google-chrome", + "/usr/bin/chromium-browser", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + ]; + for (const c of candidates) { + if (c && existsSync(c)) return c; + } + return null; +} + +async function waitForCdp(port: number, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const r = await fetch(`http://127.0.0.1:${port}/json/version`); + if (r.ok) return; + } catch { + /* retry */ + } + await new Promise((r) => setTimeout(r, 350)); + } + throw new Error(`Chrome CDP not ready on port ${port}`); +} + +async function killPortOwner(port: number): Promise { + if (process.platform !== "win32") return; + try { + const { execSync } = await import("node:child_process"); + execSync( + `powershell -NoProfile -Command "Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }"`, + { stdio: "ignore", timeout: 8000 } + ); + } catch { + /* ignore */ + } +} + +function parseCookieHeader(cookieHeader: string): Array<{ name: string; value: string }> { + const out: Array<{ name: string; value: string }> = []; + for (const part of String(cookieHeader || "").split(";")) { + const idx = part.indexOf("="); + if (idx <= 0) continue; + let name = part.slice(0, idx).trim(); + let value = part.slice(idx + 1).trim(); + try { + name = decodeURIComponent(name); + } catch { + /* keep */ + } + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (!name || /[\r\n\0]/.test(value)) continue; + out.push({ name, value }); + } + return out; +} + +/** Detect whether the process listening on `port` was started with --headless. */ +async function isPortChromeHeadless(port: number): Promise { + if (process.platform !== "win32") return null; + try { + const { execSync } = await import("node:child_process"); + const out = execSync( + `powershell -NoProfile -Command "$c=Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if(-not $c){exit 2}; $p=Get-CimInstance Win32_Process -Filter (\\"ProcessId=$($c.OwningProcess)\\"); if($p.CommandLine -match 'headless'){Write-Output 'headless'}else{Write-Output 'headed'}"`, + { encoding: "utf8", timeout: 8000, stdio: ["ignore", "pipe", "ignore"] } + ).trim(); + if (out === "headless") return true; + if (out === "headed") return false; + return null; + } catch { + return null; + } +} + +async function tryConnectExistingCdp( + chromium: typeof import("playwright").chromium, + port: number, + dir: string, + desiredMode: string, + log?: Log +): Promise { + try { + const r = await fetch(`http://127.0.0.1:${port}/json/version`); + if (!r.ok) return null; + + // Never reuse a headless Chrome for recovery — colligo rejects its Forter tokens. + // Desired "headless" may reuse headless; offscreen/visible must get a headed process. + if (desiredMode !== "headless") { + const headless = await isPortChromeHeadless(port); + if (headless === true) { + log?.warn?.( + "ADOBE-FIREFLY", + `existing CDP on ${port} is headless — killing and restarting as ${desiredMode}` + ); + await killPortOwner(port); + return null; + } + } + + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`); + const context = browser.contexts()[0] || (await browser.newContext()); + const page = await ensureLivePage(context, null); + log?.info?.( + "ADOBE-FIREFLY", + `reused existing Chrome CDP port=${port} desiredMode=${desiredMode} pages=${context.pages().length}` + ); + return { + port, + profileDir: dir, + chromeProc: null, + browser, + context, + page, + lastWarmAt: 0, + lastCookieSeed: "", + mode: desiredMode, + }; + } catch { + return null; + } +} + +/** + * Chrome remembers last window bounds in the profile. Off-screen warms park the window at + * ~(-32000,-32000) / secondary-monitor coords — a later "visible" sign-in then opens Firefly + * off-screen and the user sees nothing. Reset placement on disk before a visible spawn. + */ +function resetChromeWindowPlacementOnDisk(dir: string, log?: Log): void { + const candidates = [ + join(dir, "Default", "Preferences"), + join(dir, "Preferences"), + ]; + const onScreen = { + bottom: 960, + left: 80, + maximized: false, + right: 1360, + top: 60, + work_area_bottom: 1080, + work_area_left: 0, + work_area_right: 1920, + work_area_top: 0, + }; + for (const path of candidates) { + if (!existsSync(path)) continue; + try { + const raw = readFileSync(path, "utf8"); + const obj = JSON.parse(raw) as Record; + const browser = (obj.browser && typeof obj.browser === "object" + ? (obj.browser as Record) + : {}) as Record; + browser.window_placement = onScreen; + browser.window_placement_popup = onScreen; + obj.browser = browser; + // Avoid session restore putting us back off-screen. + if (obj.profile && typeof obj.profile === "object") { + (obj.profile as Record).exit_type = "Normal"; + (obj.profile as Record).exited_cleanly = true; + } + writeFileSync(path, JSON.stringify(obj), "utf8"); + log?.info?.("ADOBE-FIREFLY", `reset Chrome window_placement on disk (${path})`); + } catch (err) { + log?.warn?.( + "ADOBE-FIREFLY", + `could not reset window_placement: ${err instanceof Error ? err.message : String(err)}` + ); + } + } +} + +/** After CDP connect, force the browser window onto the primary work area (visible sign-in). */ +async function forceChromeWindowOnScreen( + browser: import("playwright").Browser, + page: import("playwright").Page, + log?: Log +): Promise { + try { + const cdp = await page.context().newCDPSession(page); + const { windowId } = (await cdp.send("Browser.getWindowForTarget" as "Browser.getWindowForTarget")) as { + windowId: number; + }; + await cdp.send("Browser.setWindowBounds" as "Browser.setWindowBounds", { + windowId, + bounds: { + left: 80, + top: 60, + width: 1280, + height: 900, + windowState: "normal", + }, + }); + await page.bringToFront().catch(() => {}); + // Best-effort Windows focus (Chrome can open behind the host app). + if (process.platform === "win32") { + try { + const { execSync } = await import("node:child_process"); + execSync( + `powershell -NoProfile -Command "$p=Get-Process chrome -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowTitle -match 'Firefly|Adobe|Chrome' } | Select-Object -First 1; if($p){ Add-Type -Name W -Namespace N -MemberDefinition '[DllImport(\\\"user32.dll\\\")] public static extern bool SetForegroundWindow(IntPtr h); [DllImport(\\\"user32.dll\\\")] public static extern bool ShowWindow(IntPtr h,int n);'; [N.W]::ShowWindow($p.MainWindowHandle,9) | Out-Null; [N.W]::SetForegroundWindow($p.MainWindowHandle) | Out-Null }"`, + { stdio: "ignore", timeout: 5000 } + ); + } catch { + /* ignore */ + } + } + log?.info?.("ADOBE-FIREFLY", "forced Chrome window on-screen (80,60 1280x900)"); + } catch (err) { + log?.warn?.( + "ADOBE-FIREFLY", + `forceChromeWindowOnScreen failed: ${err instanceof Error ? err.message : String(err)}` + ); + } +} + +async function ensureChromeStarted( + log?: Log, + opts?: { forceRestart?: boolean } +): Promise { + const mode = resolveChromeMode(); + + // Always kill the CDP port on forceRestart (even if in-memory runtime is null — leftover + // off-screen Chrome from a prior warm is the usual "browser didn't appear" case). + if (opts?.forceRestart) { + try { + await runtime?.browser?.close(); + } catch { + /* ignore */ + } + runtime = null; + await killPortOwner(DEFAULT_CDP_PORT); + } + + if (runtime?.browser && runtime.context) { + // If cached runtime mode is headless but we need headed Forter, restart. + if (runtime.mode === "headless" && mode !== "headless") { + log?.warn?.("ADOBE-FIREFLY", "cached Chrome is headless — restarting headed for Forter"); + try { + await runtime.browser?.close(); + } catch { + /* ignore */ + } + runtime = null; + await killPortOwner(DEFAULT_CDP_PORT); + } else { + try { + await fetch(`http://127.0.0.1:${runtime.port}/json/version`); + // Double-check process still headed when we need it + if (mode !== "headless") { + const hl = await isPortChromeHeadless(runtime.port); + if (hl === true) { + log?.warn?.("ADOBE-FIREFLY", "live CDP became headless — restarting"); + try { + await runtime.browser?.close(); + } catch { + /* ignore */ + } + runtime = null; + await killPortOwner(DEFAULT_CDP_PORT); + } else { + runtime.page = await ensureLivePage(runtime.context, runtime.page); + return runtime; + } + } else { + runtime.page = await ensureLivePage(runtime.context, runtime.page); + return runtime; + } + } catch { + try { + await runtime.browser?.close(); + } catch { + /* ignore */ + } + runtime = null; + } + } + } + + if (startingChrome) return startingChrome; + + if (process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "0") { + throw new Error("ADOBE_FIREFLY_BROWSER_REFRESH=0"); + } + + startingChrome = (async () => { + const chromePath = findChromeExecutable(); + if (!chromePath) throw new Error("Google Chrome not found (set CHROME_PATH)"); + + let chromium: typeof import("playwright").chromium; + try { + chromium = (await import("playwright")).chromium; + } catch { + throw new Error("playwright package not available for CDP connect"); + } + + const port = DEFAULT_CDP_PORT; + const dir = profileDir(); + + // Prefer reusing a healthy headed CDP (do NOT kill mid-warm — was causing "page closed"). + // Never reuse headless when desired mode is offscreen/visible. + if (!opts?.forceRestart) { + const existing = await tryConnectExistingCdp(chromium, port, dir, mode, log); + if (existing) { + runtime = existing; + return existing; + } + } + + // Kill stale listener before spawn (headless leftover / force restart). + await killPortOwner(port); + + // Visible sign-in: wipe off-screen bounds left by prior off-screen warms. + if (mode === "visible") { + resetChromeWindowPlacementOnDisk(dir, log); + } + + // Off-screen headed is the default: Forter accepts real Chrome; headless=new is rejected. + const args = [ + `--remote-debugging-port=${port}`, + `--user-data-dir=${dir}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-blink-features=AutomationControlled", + "--disable-features=TranslateUI", + "--disable-session-crashed-bubble", + "--hide-crash-restore-bubble", + ...(mode === "headless" + ? ["--headless=new", "--disable-gpu", "--window-size=1280,900"] + : mode === "offscreen" + ? [ + "--window-position=-32000,-32000", + "--window-size=1280,900", + // Start minimized as extra belt-and-suspenders (Windows may still create a taskbar entry). + "--start-minimized", + ] + : [ + // Explicit on-screen position — profile restore alone is not enough. + "--window-position=80,60", + "--window-size=1280,900", + "--start-maximized", + ]), + mode === "visible" ? "https://firefly.adobe.com/" : "https://firefly.adobe.com/generate/image", + ]; + + log?.info?.( + "ADOBE-FIREFLY", + `starting Chrome CDP profile=${dir} port=${port} mode=${mode} (offscreen=headed parked off-display; visible=on-screen sign-in)` + ); + const chromeProc = spawn(chromePath, args, { + stdio: "ignore", + detached: true, + // Hide the spawn console only; Chrome UI must remain visible for sign-in. + windowsHide: mode !== "visible", + }); + chromeProc.unref(); + + await waitForCdp(port, 45_000); + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`); + const context = browser.contexts()[0] || (await browser.newContext()); + const page = await ensureLivePage(context, null); + + if (mode === "visible") { + await forceChromeWindowOnScreen(browser, page, log); + } + + runtime = { + port, + profileDir: dir, + chromeProc, + browser, + context, + page, + lastWarmAt: 0, + lastCookieSeed: "", + mode, + }; + return runtime; + })(); + + try { + return await startingChrome; + } finally { + startingChrome = null; + } +} + +async function seedCookies( + context: import("playwright").BrowserContext, + cookieHeader: string +): Promise { + const pairs = parseCookieHeader(cookieHeader); + let n = 0; + for (const { name, value } of pairs) { + for (const domain of [".adobe.com", "firefly.adobe.com", ".firefly.adobe.com"]) { + try { + await context.addCookies([ + { name, value, domain, path: "/", secure: true, sameSite: "Lax" }, + ]); + n++; + break; + } catch { + /* try next domain */ + } + } + } + return n; +} + +function extractUserJwtFromStorageRaw(raw: string): string { + const matches = String(raw || "").match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g) || []; + for (const tok of matches) { + if (looksLikeAdobeJwt(tok) && isAdobeUserAccessToken(tok)) return tok; + } + return ""; +} + +async function readSpaUserJwt(page: import("playwright").Page): Promise { + const tokens = await page.evaluate(() => { + const out: string[] = []; + for (const key of Object.keys(sessionStorage)) { + if (!/adobeid_ims_access_token|clio-playground/i.test(key)) continue; + out.push(sessionStorage.getItem(key) || ""); + } + return out; + }); + for (const raw of tokens) { + const tok = extractUserJwtFromStorageRaw(raw); + if (tok) return tok; + } + // broader scan + const all = await page.evaluate(() => { + const out: string[] = []; + for (const key of Object.keys(sessionStorage)) out.push(sessionStorage.getItem(key) || ""); + return out; + }); + for (const raw of all) { + const tok = extractUserJwtFromStorageRaw(raw); + if (tok) return tok; + } + return ""; +} + +async function injectUserJwt(page: import("playwright").Page, token: string): Promise { + if (!token) return; + await page + .evaluate((t) => { + for (const key of Object.keys(sessionStorage)) { + if (!key.includes("adobeid_ims_access_token")) continue; + try { + const obj = JSON.parse(sessionStorage.getItem(key) || "{}") as Record; + obj.tokenValue = t; + obj.access_token = t; + obj.valid = true; + obj.expire = Date.now() + 20 * 3600 * 1000; + obj.expires_in = 86400000; + obj.client_id = "clio-playground-web"; + sessionStorage.setItem(key, JSON.stringify(obj)); + } catch { + /* skip */ + } + } + }, token) + .catch(() => {}); +} + +async function humanize(page: import("playwright").Page): Promise { + try { + if (page.isClosed()) return; + for (let i = 0; i < 16; i++) { + if (page.isClosed()) return; + await page.mouse.move(100 + i * 45, 160 + (i % 5) * 35, { steps: 4 }); + await safePageWait(page, 80); + } + // Light scroll nudges Forter / passive listeners on real headed Chrome. + await page.mouse.wheel(0, 240).catch(() => {}); + await safePageWait(page, 200); + await page.mouse.wheel(0, -120).catch(() => {}); + } catch { + /* ignore */ + } +} + +/** Poll jar until forterToken timestamp advances past `minTs`, or timeout. */ +async function waitForFresherForter( + context: import("playwright").BrowserContext, + minTs: number, + timeoutMs: number, + log?: Log +): Promise { + const start = Date.now(); + let best = 0; + while (Date.now() - start < timeoutMs) { + const cookie = await jarCookieHeader(context); + const ts = extractAdobeForterTimestampMs(cookie); + if (ts > best) best = ts; + if (ts > minTs) { + log?.info?.( + "ADOBE-FIREFLY", + `Chrome forter refreshed (ts=${ts}, deltaMs=${ts - minTs})` + ); + return ts; + } + await new Promise((r) => setTimeout(r, 1500)); + } + log?.warn?.( + "ADOBE-FIREFLY", + `Chrome forter did not advance past ${minTs} within ${timeoutMs}ms (best=${best})` + ); + return best; +} + +async function jarCookieHeader(context: import("playwright").BrowserContext): Promise { + const jar = await context.cookies(); + // Prefer firefly-relevant cookies; keep full jar for rebuild pieces + return jar.map((c) => `${c.name}=${c.value}`).join("; "); +} + +async function buildArpFromContext( + context: import("playwright").BrowserContext, + page: import("playwright").Page +): Promise<{ arp: string; cookie: string }> { + const cookie = await jarCookieHeader(context); + const ls = await page + .evaluate(() => ({ + bfp: localStorage.getItem("bfp") || "", + fpjs: localStorage.getItem("fpjs") || "", + })) + .catch(() => ({ bfp: "", fpjs: "" })); + let blob = cookie; + if (ls.bfp && !/(?:^|;\s*)bfp=/.test(blob)) blob = mergeAdobeCookieHeaders(blob, `bfp=${ls.bfp}`); + if (ls.fpjs && !/(?:^|;\s*)fpjs=/.test(blob)) { + blob = mergeAdobeCookieHeaders(blob, `fpjs=${encodeURIComponent(ls.fpjs)}`); + } + const arp = + buildAdobeArpSessionIdFromCookies(blob, { + bfp: ls.bfp || undefined, + fpjs: ls.fpjs || undefined, + }) || ""; + return { arp, cookie: extractAdobeCookieHeader(blob) || blob }; +} + +/** + * Warm (or create) the durable Chrome Firefly session. + * Returns accessToken + cookie + arpSessionId ready for generate-async. + */ +export async function warmAdobeFireflyViaChrome(opts: { + cookie: string; + accessToken?: string; + log?: Log; + /** Wait for interactive login if only guest JWT is present (ms, 0 = don't wait). */ + waitForLoginMs?: number; + /** + * Mid-batch 408 recovery: allow warm without ADOBE_FIREFLY_BROWSER_REFRESH=1. + * Uses off-screen headed Chrome by default (Forter-safe; no normal-desktop window). + */ + allowWithoutEnvOptIn?: boolean; + /** When true (or ADOBE_FIREFLY_CHROME_PING=1), prove ARP with in-page generate-async. */ + proveWithPing?: boolean; +}): Promise { + // Kill switch + if (process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "0") return null; + // Default OFF for proactive use; recovery may pass allowWithoutEnvOptIn. + if (!opts.allowWithoutEnvOptIn && process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "1") return null; + if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT) { + return null; + } + + const run = warmChain.then(async () => { + const log = opts.log; + const cookieIn = extractAdobeCookieHeader(opts.cookie) || opts.cookie; + if (!cookieIn?.trim() && !opts.accessToken) return null; + + const forterBefore = extractAdobeForterTimestampMs(cookieIn); + // Recovery path always prefers a fresh headed Chrome (stale headless CDP is poison). + const rt = await ensureChromeStarted(log, { + forceRestart: Boolean(opts.allowWithoutEnvOptIn) || process.env.ADOBE_FIREFLY_CHROME_FORCE_RESTART === "1", + }); + const context = rt.context!; + let page = await ensureLivePage(context, rt.page); + + if (cookieIn && cookieIn !== rt.lastCookieSeed) { + const n = await seedCookies(context, cookieIn); + rt.lastCookieSeed = cookieIn; + log?.info?.("ADOBE-FIREFLY", `Chrome seeded ${n} cookie entries`); + } + + // Navigate / reload with page-closed recovery (prior flaky "Target page closed"). + const gotoFirefly = async () => { + page = await ensureLivePage(context, page); + if (!/firefly\.adobe\.com/i.test(page.url())) { + await page.goto("https://firefly.adobe.com/generate/image", { + waitUntil: "domcontentloaded", + timeout: 90_000, + }); + } else { + await page.reload({ waitUntil: "domcontentloaded", timeout: 90_000 }).catch(async () => { + page = await ensureLivePage(context, null); + await page.goto("https://firefly.adobe.com/generate/image", { + waitUntil: "domcontentloaded", + timeout: 90_000, + }); + }); + } + }; + + await gotoFirefly(); + await safePageWait(page, 8_000); + await humanize(page); + + let jwt = await readSpaUserJwt(page).catch(() => ""); + if (!jwt && opts.accessToken && isAdobeUserAccessToken(opts.accessToken)) { + page = await ensureLivePage(context, page); + await injectUserJwt(page, opts.accessToken); + await page.reload({ waitUntil: "domcontentloaded", timeout: 90_000 }).catch(() => {}); + await safePageWait(page, 6_000); + await humanize(page); + jwt = (await readSpaUserJwt(page).catch(() => "")) || opts.accessToken; + log?.info?.("ADOBE-FIREFLY", "Chrome injected cached user JWT into SPA sessionStorage"); + } + + // Wait for interactive login if still no user JWT (one-time profile SSO) + const waitMs = opts.waitForLoginMs ?? Number(process.env.ADOBE_FIREFLY_LOGIN_WAIT_MS || 0); + if (!jwt && waitMs > 0) { + log?.warn?.( + "ADOBE-FIREFLY", + `No user JWT yet — sign in to Firefly in the Chrome window (wait ${Math.round(waitMs / 1000)}s)` + ); + const start = Date.now(); + while (Date.now() - start < waitMs) { + await safePageWait(page, 2000); + page = await ensureLivePage(context, page); + jwt = await readSpaUserJwt(page).catch(() => ""); + if (jwt) break; + } + } + + if (!jwt && opts.accessToken && isAdobeUserAccessToken(opts.accessToken)) { + jwt = opts.accessToken; + } + if (!jwt || !isAdobeUserAccessToken(jwt)) { + log?.warn?.("ADOBE-FIREFLY", "Chrome warm: still no AdobeID user JWT (cookie-only guest)"); + // Still return ARP if possible — caller may already have JWT + if (!opts.accessToken) return null; + jwt = opts.accessToken; + } + + // Give Forter SDK time to mint a NEW forterToken (stale paste is the usual 408 root cause). + const forterWaitMs = Number(process.env.ADOBE_FIREFLY_FORTER_WAIT_MS || 45_000); + await waitForFresherForter(context, forterBefore, forterWaitMs, log); + + // Second humanize + short settle after token land + page = await ensureLivePage(context, page); + await humanize(page); + await safePageWait(page, 2_000); + + let { arp, cookie } = await buildArpFromContext(context, page); + if (!arp) { + log?.warn?.("ADOBE-FIREFLY", "Chrome warm: could not rebuild ARP from jar — one more reload"); + await gotoFirefly(); + await safePageWait(page, 8_000); + await humanize(page); + await waitForFresherForter(context, forterBefore, 20_000, log); + ({ arp, cookie } = await buildArpFromContext(context, page)); + } + if (!arp) { + log?.warn?.("ADOBE-FIREFLY", "Chrome warm: could not rebuild ARP from jar"); + return null; + } + + // Prove colligo accepts this ARP. Default ON for recovery path; env can force either way. + const shouldPing = + opts.proveWithPing === true || + process.env.ADOBE_FIREFLY_CHROME_PING === "1" || + (opts.allowWithoutEnvOptIn && process.env.ADOBE_FIREFLY_CHROME_PING !== "0"); + if (shouldPing) { + page = await ensureLivePage(context, page); + const ok = await pingGenerateInPage(page, jwt, arp, log); + if (!ok) { + log?.warn?.( + "ADOBE-FIREFLY", + "Chrome ping generate failed — waiting for forter once more and rebuilding ARP" + ); + await waitForFresherForter(context, extractAdobeForterTimestampMs(cookie), 20_000, log); + ({ arp, cookie } = await buildArpFromContext(context, page)); + if (arp) { + page = await ensureLivePage(context, page); + const ok2 = await pingGenerateInPage(page, jwt, arp, log); + if (!ok2) { + log?.warn?.("ADOBE-FIREFLY", "Chrome ping still failed — returning ARP for node retry"); + } + } + } + } + + rt.page = page; + rt.lastWarmAt = Date.now(); + const ftrTs = extractAdobeForterTimestampMs(cookie); + log?.info?.( + "ADOBE-FIREFLY", + `Chrome warm OK (mode=${rt.mode}, arpLen=${arp.length}, forterTs=${ftrTs || 0}, forterDeltaMs=${ftrTs && forterBefore ? ftrTs - forterBefore : "n/a"}, user=${String(decodeAdobeJwtPayload(jwt)?.user_id || "").slice(0, 20)})` + ); + + return { + accessToken: jwt, + cookie, + arpSessionId: arp, + tokenExpiresAt: (() => { + const p = decodeAdobeJwtPayload(jwt); + const created = Number(p?.created_at || 0); + const exp = Number(p?.expires_in || 0); + return created && exp ? created + exp : Date.now() + 20 * 3600_000; + })(), + updatedAt: Date.now(), + fingerprint: "chrome", + source: "browser" as const, + }; + }); + + // Serialize warms + warmChain = run.then( + () => undefined, + () => undefined + ); + try { + return await run; + } catch (err) { + opts.log?.warn?.( + "ADOBE-FIREFLY", + `Chrome warm failed: ${err instanceof Error ? err.message : String(err)}` + ); + // Soft-reset page/browser handle but do not kill Chrome process — reuse next warm. + if (runtime) { + runtime.page = null; + try { + await runtime.browser?.close(); + } catch { + /* ignore */ + } + runtime.browser = null; + runtime.context = null; + } + runtime = null; + return null; + } +} + +/** + * Wipe Adobe SSO from the managed profile so "Add Account" can log into a *new* identity + * instead of silently reusing the previous Adobe session. + */ +async function clearAdobeBrowserSession( + context: import("playwright").BrowserContext, + page: import("playwright").Page, + log?: Log +): Promise { + try { + await context.clearCookies(); + } catch { + /* ignore */ + } + try { + await page.goto("https://firefly.adobe.com/", { waitUntil: "domcontentloaded", timeout: 60_000 }); + await page + .evaluate(() => { + try { + sessionStorage.clear(); + } catch { + /* ignore */ + } + try { + localStorage.clear(); + } catch { + /* ignore */ + } + }) + .catch(() => {}); + } catch { + /* ignore */ + } + // Best-effort IMS logout so the next load shows the sign-in UI. + try { + await page.goto( + "https://auth.services.adobe.com/en_US/index.html?callback=https%3A%2F%2Ffirefly.adobe.com%2F", + { + waitUntil: "domcontentloaded", + timeout: 45_000, + } + ); + await safePageWait(page, 1500); + } catch { + /* ignore */ + } + log?.info?.("ADOBE-FIREFLY", "sign-in: cleared prior Adobe session for a fresh login"); +} + +/** + * Interactive one-time sign-in for the "browser session" credential model. + * Opens a VISIBLE managed Chrome (persistent profile), navigates to Firefly, and waits for the + * user to log in. Returns the IMS JWT + cookie jar so generate works immediately without + * depending on sessionStorage surviving a browser close. + * Never throws — returns { success:false } on timeout / unavailable. + */ +export async function loginAdobeFireflyViaChrome(opts: { + cookie?: string; + /** Max time to wait for the user to complete login (ms). Default 5 min. */ + waitForLoginMs?: number; + /** + * When true (default for "Add Account"), wipe the prior Adobe SSO so a *new* account can be + * signed in instead of reopening the previous logged-in profile. + */ + freshSession?: boolean; + log?: Log; +}): Promise<{ + success: boolean; + account?: string; + accessToken?: string; + cookie?: string; + arpSessionId?: string; +}> { + if (process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "0") { + return { success: false }; + } + const log = opts.log; + const prev = modeOverride; + modeOverride = "visible"; + const fresh = opts.freshSession !== false; // default true for multi-account Add Account + try { + // Fresh visible window (a cached off-screen CDP would be parked off-display for login). + // forceRestart ALWAYS kills port 9334 + restarts with on-screen bounds. + const rt = await ensureChromeStarted(log, { forceRestart: true }); + const context = rt.context!; + let page = await ensureLivePage(context, rt.page); + + // Re-assert on-screen + foreground (profile may re-apply bad bounds after first paint). + await forceChromeWindowOnScreen(rt.browser!, page, log); + + if (fresh) { + await clearAdobeBrowserSession(context, page, log); + page = await ensureLivePage(context, null); + rt.lastCookieSeed = ""; + } else { + const cookieIn = opts.cookie ? extractAdobeCookieHeader(opts.cookie) || opts.cookie : ""; + if (cookieIn) { + const n = await seedCookies(context, cookieIn); + rt.lastCookieSeed = cookieIn; + log?.info?.("ADOBE-FIREFLY", `sign-in: seeded ${n} cookie entries as a hint`); + } + } + + await page + .goto("https://firefly.adobe.com/", { waitUntil: "domcontentloaded", timeout: 90_000 }) + .catch(() => {}); + page = await ensureLivePage(context, page); + await forceChromeWindowOnScreen(rt.browser!, page, log); + log?.info?.( + "ADOBE-FIREFLY", + `sign-in: Chrome window open ON-SCREEN (fresh=${fresh}) — waiting for Adobe login…` + ); + + const waitMs = opts.waitForLoginMs ?? Number(process.env.ADOBE_FIREFLY_LOGIN_WAIT_MS || 300_000); + const start = Date.now(); + let jwt = ""; + while (Date.now() - start < waitMs) { + await safePageWait(page, 2500); + page = await ensureLivePage(context, page); + jwt = await readSpaUserJwt(page).catch(() => ""); + if (jwt && isAdobeUserAccessToken(jwt)) break; + } + const ok = Boolean(jwt && isAdobeUserAccessToken(jwt)); + const account = ok ? String(decodeAdobeJwtPayload(jwt)?.user_id || "") : undefined; + + // Capture durable credentials BEFORE closing the window (sessionStorage JWT dies with the tab). + let cookie = ""; + let arpSessionId = ""; + if (ok) { + try { + const built = await buildArpFromContext(context, page); + cookie = extractAdobeCookieHeader(built.cookie) || built.cookie || ""; + arpSessionId = built.arp || ""; + } catch { + cookie = (await jarCookieHeader(context).catch(() => "")) || ""; + } + } + + log?.info?.( + "ADOBE-FIREFLY", + ok + ? `sign-in OK (account=${account?.slice(0, 24)}, cookieLen=${cookie.length}, arpLen=${arpSessionId.length})` + : "sign-in timed out — no AdobeID session" + ); + + // Close the visible window; the persistent profile keeps the SSO for off-screen warms. + try { + await rt.browser?.close(); + } catch { + /* ignore */ + } + runtime = null; + return { + success: ok, + account, + accessToken: ok ? jwt : undefined, + cookie: ok ? cookie : undefined, + arpSessionId: ok ? arpSessionId : undefined, + }; + } catch (err) { + log?.warn?.( + "ADOBE-FIREFLY", + `sign-in failed: ${err instanceof Error ? err.message : String(err)}` + ); + try { + await runtime?.browser?.close(); + } catch { + /* ignore */ + } + runtime = null; + return { success: false }; + } finally { + modeOverride = prev; + } +} + +async function pingGenerateInPage( + page: import("playwright").Page, + token: string, + arp: string, + log?: Log +): Promise { + try { + const res = await page.evaluate( + async ({ token, arp }) => { + const claims = JSON.parse( + atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")) + ) as { user_id?: string }; + const prompt = "ping"; + const data = new TextEncoder().encode(String(claims.user_id || "") + "-" + prompt); + const hash = await crypto.subtle.digest("SHA-256", data); + const nonce = [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join(""); + const r = await fetch("https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async", { + method: "POST", + headers: { + Authorization: "Bearer " + token, + "x-api-key": "clio-playground-web", + "content-type": "application/json", + accept: "*/*", + "x-nonce": nonce, + "x-arp-session-id": arp, + }, + credentials: "include", + body: JSON.stringify({ + n: 1, + seeds: [1], + output: { storeInputs: true }, + prompt, + referenceBlobs: [], + modelSpecificPayload: { size: "auto" }, + modelId: "gpt-image", + modelVersion: "2", + generationMetadata: { module: "text2image", submodule: "ff-image-generate" }, + generationSettings: { detailLevel: 1 }, + }), + }); + return { status: r.status, body: (await r.text()).slice(0, 120) }; + }, + { token, arp } + ); + log?.info?.("ADOBE-FIREFLY", `Chrome ping generate status=${res.status}`); + return res.status === 200 || res.status === 202; + } catch (e) { + log?.warn?.("ADOBE-FIREFLY", `Chrome ping error: ${e instanceof Error ? e.message : String(e)}`); + return false; + } +} + +/** + * Submit generate-async inside the warmed Chrome page (same TLS/cookie jar as SPA). + * Falls back to null so caller can use node fetch with the warmed ARP. + */ +export async function adobeFireflyGenerateInChrome(opts: { + accessToken: string; + arpSessionId: string; + payload: Record; + prompt: string; + log?: Log; +}): Promise<{ status: number; body: string; headers: Record } | null> { + if (!runtime?.page) return null; + try { + const res = await runtime.page.evaluate( + async ({ token, arp, payload, prompt }) => { + const claims = JSON.parse( + atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")) + ) as { user_id?: string }; + const data = new TextEncoder().encode( + String(claims.user_id || "") + "-" + String(prompt || "").slice(0, 256) + ); + const hash = await crypto.subtle.digest("SHA-256", data); + const nonce = [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join(""); + const r = await fetch("https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async", { + method: "POST", + headers: { + Authorization: "Bearer " + token, + "x-api-key": "clio-playground-web", + "content-type": "application/json", + accept: "*/*", + "x-nonce": nonce, + "x-arp-session-id": arp, + }, + credentials: "include", + body: JSON.stringify(payload), + }); + const headers: Record = {}; + r.headers.forEach((v, k) => { + headers[k] = v; + }); + return { status: r.status, body: await r.text(), headers }; + }, + { + token: opts.accessToken, + arp: opts.arpSessionId, + payload: opts.payload, + prompt: opts.prompt, + } + ); + return res; + } catch (e) { + opts.log?.warn?.( + "ADOBE-FIREFLY", + `in-Chrome generate failed: ${e instanceof Error ? e.message : String(e)}` + ); + return null; + } +} + +/** Test helper */ +export function __resetAdobeFireflyChromeRuntimeForTests(): void { + runtime = null; + warmChain = Promise.resolve(); +} diff --git a/open-sse/services/adobeFireflyClient.ts b/open-sse/services/adobeFireflyClient.ts index 3539a42fa4..abc68be133 100644 --- a/open-sse/services/adobeFireflyClient.ts +++ b/open-sse/services/adobeFireflyClient.ts @@ -1,2631 +1,2687 @@ -/** - * Adobe Firefly (unofficial) media client. - * - * Talks to the same Firefly 3P async APIs that firefly.adobe.com uses (live browser - * captures in repo `adobe/`): - * POST https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async - * POST https://firefly-3p.ff.adobe.io/v2/3p-videos/generate-async - * POST https://firefly-3p.ff.adobe.io/v2/models/discovery - * GET https://firefly.adobe.io/v1/credits/balance - * then polls BKS job result URLs rewritten from links.result. - * - * Auth is an Adobe IMS access token (Bearer, client_id = clio-playground-web). - * Callers may pass either: - * - a raw IMS access_token JWT (from Authorization: Bearer on Firefly), or - * - a browser Cookie header from firefly.adobe.com (exchanged via IMS check/v6/token - * with client_id clio-playground-web; Express projectx_webapp as fallback). - * - * x-api-key on generate/discovery MUST match the token's IMS client - * (`clio-playground-web`). Mismatch → HTTP 401 invalid token. - * - * Unofficial — tokens/cookies are short-lived; Adobe may change the wire contract. - */ - -import { createHash, randomBytes, randomUUID } from "node:crypto"; -import { resolvePublicCred } from "../utils/publicCreds.ts"; -import { sanitizeErrorMessage } from "../utils/error.ts"; - -export const ADOBE_FIREFLY_IMAGE_SUBMIT_URL = - "https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async"; -export const ADOBE_FIREFLY_VIDEO_SUBMIT_URL = - "https://firefly-3p.ff.adobe.io/v2/3p-videos/generate-async"; -export const ADOBE_FIREFLY_IMAGE_UPLOAD_URL = - "https://firefly-3p.ff.adobe.io/v2/storage/image"; -export const ADOBE_FIREFLY_MODELS_DISCOVERY_URL = - "https://firefly-3p.ff.adobe.io/v2/models/discovery"; -export const ADOBE_FIREFLY_CREDITS_BALANCE_URL = - "https://firefly.adobe.io/v1/credits/balance"; -export const ADOBE_FIREFLY_IMS_REFRESH_URL = - "https://adobeid-na1.services.adobe.com/ims/check/v6/token?jslVersion=v2-v0.48.0-1-g1e322cb"; -/** Scope set observed on live firefly.adobe.com IMS access tokens. */ -export const ADOBE_FIREFLY_IMS_SCOPE = - "AdobeID,firefly_api,openid,pps.read,pps.write,additional_info.projectedProductContext," + - "additional_info.ownerOrg,uds_read,uds_write,ab.manage,read_organizations," + - "additional_info.roles,account_cluster.read,creative_production,tk_platform," + - "tk_platform_sync,profile"; - -const DEFAULT_USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"; -const DEFAULT_SEC_CH_UA = - '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"'; -const DEFAULT_POLL_INTERVAL_MS = 3000; -const DEFAULT_IMAGE_TIMEOUT_MS = 180_000; -const DEFAULT_VIDEO_TIMEOUT_MS = 300_000; -const FIREFLY_ORIGIN = "https://firefly.adobe.com"; -const FIREFLY_REFERER = "https://firefly.adobe.com/"; - -export type AdobeFireflyImageModelId = - | "nano-banana-pro" - | "nano-banana" - | "nano-banana-2" - | "gpt-image" - | "gpt-image-2" - | "gpt-image-1.5" - | "flux-2" - | "flux-pro" - | "flux-ultra" - | "seedream-4" - | "seedream-5-lite" - | "runway-gen4-image"; - -export type AdobeFireflyVideoModelId = - | "sora-2" - | "sora-2-pro" - | "veo-3.1" - | "veo-3.1-fast" - | "veo-3.1-ref" - | "kling-3"; - -export interface AdobeFireflyImageModelSpec { - upstreamModelId: string; - upstreamModelVersion: string; - /** Payload builder family — nano uses Gemini-style size maps; gpt-image uses OpenAI detail levels. */ - family: "nano" | "gpt-image" | "generic"; -} - -export interface AdobeFireflyVideoModelSpec { - engine: "sora2" | "sora2-pro" | "veo31-standard" | "veo31-fast" | "kling3"; - upstreamModel: string; - modelId?: string; - modelVersion?: string; - referenceMode?: "frame" | "image"; - defaultDuration: number; - defaultResolution: string; -} - -/** - * Upstream modelId/modelVersion pairs from firefly-3p models/discovery - * (captured 2026-07 — see adobe/get_models.txt). Friendly catalog ids map here. - */ -export const ADOBE_FIREFLY_IMAGE_MODELS: Record = - { - // Gemini 3.0 (Nano Banana Pro) — discovery: gemini-flash / nano-banana-2 - "nano-banana-pro": { - upstreamModelId: "gemini-flash", - upstreamModelVersion: "nano-banana-2", - family: "nano", - }, - // Gemini 2.5 (Nano Banana) — discovery: gemini-flash / nano-banana - "nano-banana": { - upstreamModelId: "gemini-flash", - upstreamModelVersion: "nano-banana", - family: "nano", - }, - // Gemini 3.1 (Nano Banana 2) — discovery: gemini-flash / nano-banana-3 - "nano-banana-2": { - upstreamModelId: "gemini-flash", - upstreamModelVersion: "nano-banana-3", - family: "nano", - }, - // GPT Image 2 — discovery modelVersion "2" (get_models: modelDisplayName "GPT Image 2") - "gpt-image": { - upstreamModelId: "gpt-image", - upstreamModelVersion: "2", - family: "gpt-image", - }, - // Explicit catalog alias so pickers show "gpt-image-2" distinctly - "gpt-image-2": { - upstreamModelId: "gpt-image", - upstreamModelVersion: "2", - family: "gpt-image", - }, - "gpt-image-1.5": { - upstreamModelId: "gpt-image", - upstreamModelVersion: "1.5", - family: "gpt-image", - }, - "flux-2": { - upstreamModelId: "flux", - upstreamModelVersion: "2", - family: "generic", - }, - "flux-pro": { - upstreamModelId: "flux", - upstreamModelVersion: "fluxPro", - family: "generic", - }, - "flux-ultra": { - upstreamModelId: "flux", - upstreamModelVersion: "fluxUltra", - family: "generic", - }, - "seedream-4": { - upstreamModelId: "seedream", - upstreamModelVersion: "seedream_v4", - family: "generic", - }, - "seedream-5-lite": { - upstreamModelId: "seedream", - upstreamModelVersion: "seedream_v5_lite", - family: "generic", - }, - "runway-gen4-image": { - upstreamModelId: "runway-gen4-image", - upstreamModelVersion: "gen4_image", - family: "generic", - }, - }; - -export const ADOBE_FIREFLY_VIDEO_MODELS: Record = - { - "sora-2": { - engine: "sora2", - upstreamModel: "openai:firefly:colligo:sora2", - defaultDuration: 8, - defaultResolution: "720p", - }, - "sora-2-pro": { - engine: "sora2-pro", - upstreamModel: "openai:firefly:colligo:sora2-pro", - defaultDuration: 8, - defaultResolution: "720p", - }, - "veo-3.1": { - engine: "veo31-standard", - upstreamModel: "google:firefly:colligo:veo31", - modelId: "veo", - modelVersion: "3.1-generate", - defaultDuration: 6, - defaultResolution: "720p", - }, - "veo-3.1-fast": { - engine: "veo31-fast", - upstreamModel: "google:firefly:colligo:veo31-fast", - modelId: "veo", - modelVersion: "3.1-fast-generate", - defaultDuration: 6, - defaultResolution: "720p", - }, - "veo-3.1-ref": { - engine: "veo31-standard", - upstreamModel: "google:firefly:colligo:veo31", - modelId: "veo", - modelVersion: "3.1-generate", - referenceMode: "image", - defaultDuration: 6, - defaultResolution: "720p", - }, - "kling-3": { - engine: "kling3", - upstreamModel: "kling:firefly:colligo:kling3", - modelId: "kling", - modelVersion: "kling_v3_standard_i2v", - defaultDuration: 5, - defaultResolution: "1080p", - }, - }; - -const NANO_SIZE_MAP: Record> = { - "1K": { - "1:1": { width: 1024, height: 1024 }, - "16:9": { width: 1360, height: 768 }, - "9:16": { width: 768, height: 1360 }, - "4:3": { width: 1152, height: 864 }, - "3:4": { width: 864, height: 1152 }, - "1:8": { width: 384, height: 3072 }, - "1:4": { width: 512, height: 2048 }, - "4:1": { width: 2048, height: 512 }, - "8:1": { width: 3072, height: 384 }, - }, - "2K": { - "1:1": { width: 2048, height: 2048 }, - "16:9": { width: 2752, height: 1536 }, - "9:16": { width: 1536, height: 2752 }, - "4:3": { width: 2048, height: 1536 }, - "3:4": { width: 1536, height: 2048 }, - "1:8": { width: 768, height: 6144 }, - "1:4": { width: 1024, height: 4096 }, - "4:1": { width: 4096, height: 1024 }, - "8:1": { width: 6144, height: 768 }, - }, - "4K": { - "1:1": { width: 4096, height: 4096 }, - "16:9": { width: 5504, height: 3072 }, - "9:16": { width: 3072, height: 5504 }, - "4:3": { width: 4096, height: 3072 }, - "3:4": { width: 3072, height: 4096 }, - "1:8": { width: 1536, height: 12288 }, - "1:4": { width: 2048, height: 8192 }, - "4:1": { width: 8192, height: 2048 }, - "8:1": { width: 12288, height: 1536 }, - }, -}; - -const GPT_SIZE_MAP: Record> = { - "1K": { - "1:1": { width: 1024, height: 1024 }, - "5:4": { width: 1120, height: 896 }, - "9:16": { width: 720, height: 1280 }, - "21:9": { width: 1456, height: 624 }, - "16:9": { width: 1280, height: 720 }, - "4:3": { width: 1152, height: 864 }, - "3:2": { width: 1248, height: 832 }, - "4:5": { width: 896, height: 1120 }, - "3:4": { width: 864, height: 1152 }, - "2:3": { width: 832, height: 1248 }, - }, - "2K": { - "1:1": { width: 2048, height: 2048 }, - "5:4": { width: 2240, height: 1792 }, - "9:16": { width: 1440, height: 2560 }, - "21:9": { width: 3024, height: 1296 }, - "16:9": { width: 2560, height: 1440 }, - "4:3": { width: 2304, height: 1728 }, - "3:2": { width: 2496, height: 1664 }, - "4:5": { width: 1792, height: 2240 }, - "3:4": { width: 1728, height: 2304 }, - "2:3": { width: 1664, height: 2496 }, - }, - "4K": { - "1:1": { width: 2880, height: 2880 }, - "5:4": { width: 3200, height: 2560 }, - "9:16": { width: 2160, height: 3840 }, - "21:9": { width: 3696, height: 1584 }, - "16:9": { width: 3840, height: 2160 }, - "4:3": { width: 3264, height: 2448 }, - "3:2": { width: 3504, height: 2336 }, - "4:5": { width: 2560, height: 3200 }, - "3:4": { width: 2448, height: 3264 }, - "2:3": { width: 2336, height: 3504 }, - }, -}; - -const PIXEL_SIZE_TO_RATIO: Record = { - "1024x1024": "1:1", - "1536x1536": "1:1", - "2048x2048": "1:1", - "1024x1792": "9:16", - "1536x2752": "9:16", - "1792x1024": "16:9", - "2752x1536": "16:9", - "2048x1536": "4:3", - "1536x2048": "3:4", - "1280x720": "16:9", - "720x1280": "9:16", - "1920x1080": "16:9", - "1080x1920": "9:16", -}; - -export class AdobeFireflyError extends Error { - status: number; - code?: string; - - constructor(message: string, status = 502, code?: string) { - super(message); - this.name = "AdobeFireflyError"; - this.status = status; - this.code = code; - } -} - -/** Public x-api-key + primary IMS client_id for firefly.adobe.com (`clio-playground-web`). */ -export function adobeFireflyApiKey(): string { - return resolvePublicCred("adobe_firefly_api_key", "ADOBE_FIREFLY_API_KEY"); -} - -/** Express IMS client_id fallback for cookie exchange (`projectx_webapp`). */ -export function adobeFireflyExpressClientId(): string { - return resolvePublicCred("adobe_firefly_express_client_id", "ADOBE_FIREFLY_EXPRESS_CLIENT_ID"); -} - -/** Public x-api-key for GET firefly.adobe.io/v1/credits/balance (`SunbreakWebUI1`). */ -export function adobeFireflyBalanceApiKey(): string { - return resolvePublicCred("adobe_firefly_balance_api_key", "ADOBE_FIREFLY_BALANCE_API_KEY"); -} - -/** Decode IMS JWT payload (no signature verification — client-side claim read only). */ -export function decodeAdobeJwtPayload(token: string): Record | null { - try { - // Do not call extractAdobeCredentialToken here (would recurse via guest checks). - let raw = String(token || "").trim().replace(/^bearer\s+/i, "").trim(); - // If a blob was passed, take the first JWT-shaped segment. - const m = raw.match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/); - if (m) raw = m[0]; - const part = raw.split(".")[1]; - if (!part) return null; - const json = Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"); - const obj = JSON.parse(json); - return obj && typeof obj === "object" ? (obj as Record) : null; - } catch { - return null; - } -} - -/** AdobeID subject for x-account-id on balance / account_cluster calls. */ -export function extractAdobeAccountIdFromToken(token: string): string { - const payload = decodeAdobeJwtPayload(token); - if (!payload) return ""; - const candidates = [payload.user_id, payload.aa_id, payload.sub, payload.id]; - for (const c of candidates) { - if (typeof c === "string" && c.includes("@")) return c.trim(); - } - for (const c of candidates) { - if (typeof c === "string" && c.trim()) return c.trim(); - } - return ""; -} - -export function looksLikeAdobeJwt(value: string): boolean { - const raw = value.trim(); - if (!raw) return false; - // Avoid treating cookie blobs that happen to have two dots as JWT. - if (raw.includes(";") || (raw.includes("=") && !raw.startsWith("eyJ"))) return false; - // Allow a single space after optional Bearer prefix (stripped earlier). - if (/\s/.test(raw) && !/^bearer\s+/i.test(raw)) return false; - const token = raw.replace(/^bearer\s+/i, "").trim(); - const parts = token.split("."); - if (parts.length !== 3) return false; - // Adobe IMS access tokens are sizable; reject tiny accidental 3-segment strings. - if (token.length < 80) return false; - return parts.every((p) => p.length > 0 && /^[A-Za-z0-9_-]+$/.test(p)); -} - -/** - * True when IMS issued a guest token (no signed-in AdobeID). - * Live repro: firefly.adobe.com page cookies alone → account_type=guest → generate 401 / - * balance 403 ErrMismatchOauthToken. - */ -export function isAdobeGuestAccessToken(token: string): boolean { - const payload = decodeAdobeJwtPayload(token); - if (!payload) return false; - const userId = typeof payload.user_id === "string" ? payload.user_id : ""; - const aaId = typeof payload.aa_id === "string" ? payload.aa_id : ""; - const type = typeof payload.type === "string" ? payload.type.toLowerCase() : ""; - // Authenticated Firefly tokens always carry an @AdobeID (or similar) subject. - if (userId.includes("@AdobeID") || aaId.includes("@AdobeID")) return false; - if (userId.includes("@GuestID") || aaId.includes("@GuestID")) return true; - if (type === "guest" || type.includes("guest")) return true; - // Guest tokens from ims/check often omit type/user_id entirely. - if (!userId && !aaId) return true; - return false; -} - -export function isAdobeUserAccessToken(token: string): boolean { - return looksLikeAdobeJwt(token) && !isAdobeGuestAccessToken(token); -} - -/** - * Pull an IMS JWT out of free-form paste: raw JWT, Bearer …, access_token=…, - * IMS sessionStorage JSON (`tokenValue`), multi-line Network/HAR dumps. - * Prefer the longest user (non-guest) eyJ… JWT found. - */ -export function extractAdobeCredentialToken(raw: string): string { - const value = String(raw || "").trim(); - if (!value) return ""; - - if (/^bearer\s+/i.test(value)) { - const bare = value.replace(/^bearer\s+/i, "").trim().split(/\s+/)[0] || ""; - if (looksLikeAdobeJwt(bare)) return bare; - } - - // access_token=... in cookie-ish or form paste - const accessMatch = value.match(/(?:^|[;\s&])access_token=([^;\s&]+)/i); - if (accessMatch?.[1]) { - const t = decodeURIComponent(accessMatch[1].trim()); - if (looksLikeAdobeJwt(t)) return t; - } - - // IMS sessionStorage / localStorage JSON: "tokenValue":"eyJ..." - const tokenValueMatch = value.match(/"tokenValue"\s*:\s*"(eyJ[^"]+)"/i); - if (tokenValueMatch?.[1] && looksLikeAdobeJwt(tokenValueMatch[1])) { - return tokenValueMatch[1]; - } - - // Authorization: Bearer eyJ... - const authMatch = value.match(/Authorization\s*:\s*Bearer\s+([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i); - if (authMatch?.[1] && looksLikeAdobeJwt(authMatch[1])) return authMatch[1]; - - // Any eyJ… JWT in the blob (HAR / multi-line). Prefer user AdobeID tokens. - const jwtMatches = value.match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g); - if (jwtMatches && jwtMatches.length > 0) { - const sorted = [...jwtMatches].sort((a, b) => b.length - a.length); - const user = sorted.find((t) => looksLikeAdobeJwt(t) && isAdobeUserAccessToken(t)); - if (user) return user; - const best = sorted[0]; - if (looksLikeAdobeJwt(best)) return best; - } - - // Pure JWT - if (looksLikeAdobeJwt(value)) return value.replace(/^bearer\s+/i, "").trim(); - - // Cookie / other blob unchanged for IMS exchange - return value; -} - -/** - * True when the paste still looks like a Cookie header (not a bare JWT). - * Used to attach Cookie + sherlockToken → x-arp-session-id on generate. - */ -export function looksLikeAdobeCookieBlob(value: string): boolean { - const raw = String(value || "").trim(); - if (!raw || looksLikeAdobeJwt(raw)) return false; - if (raw.includes(";") && raw.includes("=")) return true; - if (/(?:^|[;\s])(?:aux_sid|ff_session|sherlockToken|forterToken|arkose)=/i.test(raw)) { - return true; - } - return false; -} - -/** - * Strip JWTs / Authorization lines from a mixed paste so only Cookie pairs remain. - * Undici Headers.append rejects multi-line Cookie values (throws Headers.append: "eyJ…"). - */ -export function extractAdobeCookieHeader(raw: string): string { - const value = String(raw || "").trim(); - if (!value) return ""; - if (looksLikeAdobeJwt(value)) return ""; - - // Drop pure JWT lines and Authorization: Bearer lines - const cleaned = value - .split(/[\r\n]+/) - .map((line) => line.trim()) - .filter((line) => { - if (!line) return false; - if (/^authorization\s*:/i.test(line)) return false; - if (/^bearer\s+/i.test(line)) return false; - if (looksLikeAdobeJwt(line)) return false; - // Drop standalone eyJ… segments - if (/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(line)) return false; - return true; - }) - .join("; "); - - // Also strip inline eyJ JWT tokens that may sit inside a cookie string - const noJwt = cleaned - .replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "") - .replace(/;\s*;/g, ";") - .replace(/^;\s*|\s*;$/g, "") - .trim(); - - if (!noJwt || !looksLikeAdobeCookieBlob(noJwt)) return ""; - // Final safety: Cookie header must be single-line - return noJwt.replace(/[\r\n]+/g, "; ").trim(); -} - -const GUEST_COOKIE_HELP = - "Firefly page cookies alone only mint a GUEST IMS token (no AdobeID) — generate returns 401 and Limits 403. " + - "Fix: open firefly.adobe.com signed-in → F12 → Network → click a request to firefly-3p.ff.adobe.io " + - "(generate-async or models/discovery) → Request Headers → Authorization → copy the token AFTER 'Bearer ' " + - "(starts with eyJ…). Paste that JWT as the credential. " + - "Cookie-only works only if you also export IMS session cookies from adobelogin.com / adobeid-na1 " + - "(Cookie-Editor → export all Adobe domains); firefly.adobe.com cookies by themselves are not enough."; - -export function normalizeAdobeAspectRatio(sizeOrRatio: unknown, fallback = "1:1"): string { - if (typeof sizeOrRatio !== "string" || !sizeOrRatio.trim()) return fallback; - let raw = sizeOrRatio.trim().replace(/_/g, ":"); - if (raw.toLowerCase() === "auto") return fallback; - - if (/^\d+:\d+$/.test(raw)) return raw; - - // Short ratio forms like 16x9 / 9x16 - const short = raw.match(/^(\d+)x(\d+)$/i); - if (short) { - const a = Number(short[1]); - const b = Number(short[2]); - if (a > 0 && b > 0 && a < 100 && b < 100) return `${a}:${b}`; - } - - const lower = raw.toLowerCase(); - if (PIXEL_SIZE_TO_RATIO[lower]) return PIXEL_SIZE_TO_RATIO[lower]; - - // Generic WxH pixel sizes → closest common ratio - const pixel = lower.match(/^(\d+)x(\d+)$/); - if (pixel) { - const w = Number(pixel[1]); - const h = Number(pixel[2]); - if (w > 0 && h > 0) { - if (Math.abs(w - h) / Math.max(w, h) < 0.08) return "1:1"; - if (w > h * 1.5) return "16:9"; - if (h > w * 1.5) return "9:16"; - if (w > h) return "4:3"; - return "3:4"; - } - } - - return fallback; -} - -export function normalizeAdobeOutputResolution(quality: unknown, size: unknown): "1K" | "2K" | "4K" { - const q = String(quality ?? "").trim().toLowerCase(); - if (q === "4k" || q === "ultra" || q === "high") return "4K"; - if (q === "2k" || q === "hd" || q === "standard" || q === "medium") return "2K"; - if (q === "1k" || q === "low") return "1K"; - - const s = String(size ?? "").toLowerCase(); - if (s.includes("4k") || /4096|5504|3840/.test(s)) return "4K"; - if (s.includes("1k") || /1024x1024|768x1360|1360x768/.test(s)) return "1K"; - return "2K"; -} - -export function resolveAdobeImageModel(model: string): { - id: AdobeFireflyImageModelId; - spec: AdobeFireflyImageModelSpec; -} { - const raw = String(model || "") - .trim() - .toLowerCase() - .replace(/^adobe-firefly\//, "") - .replace(/^firefly\//, ""); - - // Accept long catalog ids like firefly-nano-banana-pro-2k-16x9 - if (raw.includes("nano-banana2") || raw.includes("nano-banana-2") || raw.includes("nano-banana-3")) { - return { id: "nano-banana-2", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-2"] }; - } - if (raw.includes("nano-banana-pro")) { - return { id: "nano-banana-pro", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"] }; - } - if (raw.includes("nano-banana")) { - return { id: "nano-banana", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana"] }; - } - if (raw.includes("gpt-image-1.5") || raw.includes("gpt-image1.5")) { - return { id: "gpt-image-1.5", spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image-1.5"] }; - } - // Prefer explicit "2" / "gpt-image-2" before generic gpt-image - if ( - raw === "gpt-image-2" || - raw.includes("gpt-image-2") || - raw.includes("gptimage2") || - raw === "gpt-image" || - raw.includes("gpt-image") - ) { - // Bare gpt-image and gpt-image-2 both map to upstream version "2" (GPT Image 2). - if (raw.includes("1.5")) { - return { id: "gpt-image-1.5", spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image-1.5"] }; - } - const id = raw.includes("gpt-image-2") || raw.includes("gptimage2") ? "gpt-image-2" : "gpt-image"; - return { id: id as AdobeFireflyImageModelId, spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"] }; - } - if (raw.includes("flux-ultra") || raw.includes("fluxultra")) { - return { id: "flux-ultra", spec: ADOBE_FIREFLY_IMAGE_MODELS["flux-ultra"] }; - } - if (raw.includes("flux-pro") || raw.includes("fluxpro")) { - return { id: "flux-pro", spec: ADOBE_FIREFLY_IMAGE_MODELS["flux-pro"] }; - } - if (raw.includes("flux")) { - return { id: "flux-2", spec: ADOBE_FIREFLY_IMAGE_MODELS["flux-2"] }; - } - if (raw.includes("seedream-5") || raw.includes("seedream_v5")) { - return { id: "seedream-5-lite", spec: ADOBE_FIREFLY_IMAGE_MODELS["seedream-5-lite"] }; - } - if (raw.includes("seedream")) { - return { id: "seedream-4", spec: ADOBE_FIREFLY_IMAGE_MODELS["seedream-4"] }; - } - if (raw.includes("runway") && raw.includes("image")) { - return { id: "runway-gen4-image", spec: ADOBE_FIREFLY_IMAGE_MODELS["runway-gen4-image"] }; - } - - if (raw in ADOBE_FIREFLY_IMAGE_MODELS) { - const id = raw as AdobeFireflyImageModelId; - return { id, spec: ADOBE_FIREFLY_IMAGE_MODELS[id] }; - } - - // Default to Nano Banana Pro (most common Firefly image path). - return { id: "nano-banana-pro", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"] }; -} - -export function resolveAdobeVideoModel(model: string): { - id: AdobeFireflyVideoModelId; - spec: AdobeFireflyVideoModelSpec; -} { - const raw = String(model || "") - .trim() - .toLowerCase() - .replace(/^adobe-firefly\//, "") - .replace(/^firefly\//, ""); - - if (raw.includes("sora2-pro") || raw.includes("sora-2-pro") || raw.includes("sora2_pro")) { - return { id: "sora-2-pro", spec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2-pro"] }; - } - if (raw.includes("sora2") || raw.includes("sora-2") || raw.includes("sora")) { - return { id: "sora-2", spec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2"] }; - } - if (raw.includes("veo31-ref") || raw.includes("veo-3.1-ref") || raw.includes("veo31_ref")) { - return { id: "veo-3.1-ref", spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1-ref"] }; - } - if (raw.includes("veo31-fast") || raw.includes("veo-3.1-fast") || raw.includes("veo31_fast")) { - return { id: "veo-3.1-fast", spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1-fast"] }; - } - if (raw.includes("veo31") || raw.includes("veo-3.1") || raw.includes("veo")) { - return { id: "veo-3.1", spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1"] }; - } - if (raw.includes("kling")) { - return { id: "kling-3", spec: ADOBE_FIREFLY_VIDEO_MODELS["kling-3"] }; - } - - if (raw in ADOBE_FIREFLY_VIDEO_MODELS) { - const id = raw as AdobeFireflyVideoModelId; - return { id, spec: ADOBE_FIREFLY_VIDEO_MODELS[id] }; - } - - return { id: "sora-2", spec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2"] }; -} - -function gptDetailLevel(quality: unknown): number { - // Live firefly.adobe.com default for gpt-image is detailLevel 3 (medium). - const q = String(quality ?? "medium").trim().toLowerCase(); - if (q === "high" || q === "4k" || q === "ultra") return 5; - if (q === "low" || q === "1k") return 1; - if (q === "medium" || q === "2k" || q === "standard" || q === "hd" || q === "auto") return 3; - return 3; -} - -export function buildAdobeImagePayload(opts: { - prompt: string; - aspectRatio: string; - outputResolution: "1K" | "2K" | "4K"; - modelSpec: AdobeFireflyImageModelSpec; - quality?: unknown; - seed?: number; - sourceImageIds?: string[]; - negativePrompt?: string; -}): Record { - const ratio = opts.aspectRatio === "auto" ? "1:1" : opts.aspectRatio || "1:1"; - const seeds = [typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999)]; - const negative = String(opts.negativePrompt || "").trim(); - const genSettings: Record = {}; - if (negative) { - genSettings.avoidKeywords = negative - .replace(/;/g, ",") - .split(",") - .map((w) => w.trim()) - .filter(Boolean); - } - - if (opts.modelSpec.family === "gpt-image") { - // Live firefly.adobe.com body (adobe/image_generate.txt) — no top-level size / - // outputResolution; modelSpecificPayload.size is "auto". - const payload: Record = { - n: 1, - seeds, - output: { storeInputs: true }, - prompt: opts.prompt, - referenceBlobs: [] as Array>, - modelSpecificPayload: { size: "auto" }, - modelId: opts.modelSpec.upstreamModelId, - modelVersion: opts.modelSpec.upstreamModelVersion, - generationMetadata: { module: "text2image", submodule: "ff-image-generate" }, - generationSettings: { - detailLevel: gptDetailLevel(opts.quality), - ...genSettings, - }, - }; - if (opts.sourceImageIds?.length) { - // gpt-image subject references (mask path uses separate mask blob when present). - payload.generationMetadata = { module: "image2image", submodule: "ff-image-generate" }; - payload.referenceBlobs = opts.sourceImageIds.map((id) => ({ - id: String(id), - usage: "subject", - })); - payload.modelSpecificPayload = {}; - } - return payload; - } - - // nano (Gemini Flash) + generic (Flux / Seedream / Runway image): same 3P image shape. - // Live capture (browser network capture): referenceBlobs with usage "general" - // keep module "text2image" (not image2image) for nano multi-ref composition. - const sizeMap = NANO_SIZE_MAP[opts.outputResolution] || NANO_SIZE_MAP["2K"]; - const pixel = sizeMap[ratio] || sizeMap["1:1"]; - const payload: Record = { - modelId: opts.modelSpec.upstreamModelId, - modelVersion: opts.modelSpec.upstreamModelVersion, - n: 1, - prompt: opts.prompt, - size: pixel, - seeds, - groundSearch: false, - skipCai: false, - output: { storeInputs: true }, - generationMetadata: { module: "text2image", submodule: "ff-image-generate" }, - modelSpecificPayload: { - parameters: { addWatermark: false }, - aspectRatio: ratio, - }, - referenceBlobs: [] as Array>, - }; - if (Object.keys(genSettings).length) payload.generationSettings = genSettings; - - if (opts.sourceImageIds?.length) { - payload.referenceBlobs = opts.sourceImageIds.map((id) => ({ - id: String(id), - usage: "general", - })); - // Flux / Seedream / Runway image historically used image2image; nano keeps text2image. - if (opts.modelSpec.family === "generic") { - payload.generationMetadata = { module: "image2image", submodule: "ff-image-generate" }; - } - } - return payload; -} - -function videoSize(aspectRatio: string, resolution: string): { width: number; height: number } { - const res = String(resolution || "720p").toLowerCase(); - const short = res.includes("1080") ? 1080 : res.includes("480") ? 480 : 720; - const ratio = aspectRatio === "9:16" ? "9:16" : aspectRatio === "1:1" ? "1:1" : "16:9"; - if (ratio === "1:1") return { width: short, height: short }; - if (ratio === "9:16") return { width: Math.round((short * 9) / 16), height: short }; - return { width: Math.round((short * 16) / 9), height: short }; -} - -export function buildAdobeVideoPayload(opts: { - prompt: string; - aspectRatio: string; - duration: number; - modelSpec: AdobeFireflyVideoModelSpec; - resolution?: string; - seed?: number; - sourceImageIds?: string[]; - negativePrompt?: string; - generateAudio?: boolean; -}): Record { - const seedVal = typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999); - const aspect = opts.aspectRatio === "auto" ? "16:9" : opts.aspectRatio || "16:9"; - const duration = Math.max(1, Math.min(30, Math.floor(opts.duration || opts.modelSpec.defaultDuration))); - const resolution = opts.resolution || opts.modelSpec.defaultResolution; - const vidSize = videoSize(aspect, resolution); - const engine = opts.modelSpec.engine; - const sourceImageIds = opts.sourceImageIds || []; - const negative = String(opts.negativePrompt || ""); - - if (engine === "veo31-standard" || engine === "veo31-fast") { - const payload: Record = { - n: 1, - seeds: [seedVal], - modelId: "veo", - modelVersion: - opts.modelSpec.modelVersion || - (engine === "veo31-fast" ? "3.1-fast-generate" : "3.1-generate"), - output: { storeInputs: true }, - prompt: opts.prompt, - size: vidSize, - generateAudio: opts.generateAudio !== false, - referenceBlobs: [] as Array>, - generationMetadata: { module: "text2video" }, - modelSpecificPayload: { - parameters: { - durationSeconds: duration, - aspectRatio: aspect, - addWaterMark: false, - }, - }, - }; - if (sourceImageIds.length) { - const refs = payload.referenceBlobs as Array>; - if (opts.modelSpec.referenceMode === "image") { - for (const imageId of sourceImageIds.slice(0, 3)) { - refs.push({ id: String(imageId), usage: "asset" }); - } - } else { - sourceImageIds.slice(0, 2).forEach((imageId, idx) => { - refs.push({ id: String(imageId), usage: "general", order: idx + 1 }); - }); - } - payload.generationMetadata = { module: "image2video" }; - } - if (negative) payload.negativePrompt = negative; - return payload; - } - - if (engine === "kling3") { - const payload: Record = { - n: 1, - seeds: [seedVal], - modelId: "kling", - modelVersion: "kling_v3_standard_i2v", - output: { storeInputs: true }, - prompt: opts.prompt, - size: vidSize, - generationMetadata: { - module: sourceImageIds.length ? "image2video" : "text2video", - }, - duration, - generationSettings: { aspectRatio: aspect }, - referenceBlobs: [] as Array>, - }; - if (sourceImageIds.length) { - const refs = payload.referenceBlobs as Array>; - sourceImageIds.slice(0, 2).forEach((imageId, idx) => { - refs.push({ id: String(imageId), usage: "frame", order: idx + 1 }); - }); - } - if (negative) payload.negativePrompt = negative; - return payload; - } - - // Sora 2 / Sora 2 Pro - const promptJson = JSON.stringify({ - prompt: opts.prompt, - duration, - ...(negative ? { negative_prompt: negative } : {}), - }); - const payload: Record = { - n: 1, - seeds: [seedVal], - modelId: "sora", - modelVersion: engine === "sora2-pro" ? "sora-2-pro" : "sora-2", - size: vidSize, - duration, - fps: 24, - prompt: promptJson, - generationMetadata: { module: sourceImageIds.length ? "image2video" : "text2video" }, - model: opts.modelSpec.upstreamModel, - generateLoop: false, - transparentBackground: false, - seed: String(seedVal), - locale: "en-US", - camera: { angle: "none", shotSize: "none", motion: null, promptStyle: null }, - negativePrompt: negative, - jobMode: "standard", - debugGenerationEndpoint: "", - referenceBlobs: [] as Array>, - referenceFrames: [] as Array | null>, - referenceVideo: null, - cameraMotionReferenceVideo: null, - characterReference: null, - editReferenceVideo: null, - output: { storeInputs: true }, - }; - if (sourceImageIds.length) { - const firstId = String(sourceImageIds[0]); - payload.referenceBlobs = [{ id: firstId, usage: "general", promptReference: 1 }]; - const frames: Array | null> = [{ localBlobRef: firstId }, null]; - if (sourceImageIds.length > 1) { - const lastId = String(sourceImageIds[1]); - (payload.referenceBlobs as Array>).push({ - id: lastId, - usage: "general", - promptReference: 2, - }); - frames[1] = { localBlobRef: lastId }; - } - payload.referenceFrames = frames; - } - return payload; -} - -function browserHeaders(): Record { - return { - "user-agent": DEFAULT_USER_AGENT, - origin: FIREFLY_ORIGIN, - referer: FIREFLY_REFERER, - "accept-language": "en-US,en;q=0.9", - "sec-ch-ua": DEFAULT_SEC_CH_UA, - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"Windows"', - "sec-fetch-site": "cross-site", - "sec-fetch-mode": "cors", - "sec-fetch-dest": "empty", - }; -} - -/** Random 64-char hex fallback when token/prompt are missing for deterministic nonce. */ -export function generateAdobeNonce(): string { - const bytes = new Uint8Array(32); - if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { - crypto.getRandomValues(bytes); - } else { - for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); - } - return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); -} - -/** - * Deterministic x-nonce used by working open-source Firefly clients - * (adobe2api / GPT2Image-Pro / image2api): - * sha256(`${user_id}-${prompt.slice(0, 256)}`) - * - * Random nonces (browser-looking) still get colligo 408 on many accounts when - * the request is not from the SPA. Deterministic nonce is what unblocks generate. - */ -export function buildAdobeSubmitNonce(accessToken: string, prompt: string): string { - const userId = extractAdobeAccountIdFromToken(accessToken); - const promptPrefix = String(prompt || "").slice(0, 256); - if (!userId || !promptPrefix) return ""; - return createHash("sha256").update(`${userId}-${promptPrefix}`, "utf8").digest("hex"); -} - -/** - * Live firefly.adobe.com Arkose public key (browser network capture, 2026-07). - * Browser x-arp-session-id is base64(JSON({sid, ark, ftr})) — synthetic sessions without a - * real Arkose blob often get colligo HTTP 408 "system under load". Prefer pasted sherlockToken. - */ -export const ADOBE_FIREFLY_ARKOSE_PUBLIC_KEY = "BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C"; -/** Live ftr magic (replaces older adobe2api `dUAL43-mnts-ants-d4_31ck__tt`). */ -export const ADOBE_FIREFLY_FTR_MAGIC = "__UDF43-m4_31ck"; - -/** - * True when a string looks like a Firefly ARP session (base64 JSON with sid). - */ -export function isValidAdobeArpSessionId(value: string): boolean { - const t = String(value || "").trim(); - if (t.length < 4) return false; - // Never treat Cookie name=value pairs (e.g. aux_sid=…, forter=…) as ARP. - // Live ARP is base64(JSON) or a bare opaque token — not "key=value". - if (/^[A-Za-z_][A-Za-z0-9_.%-]*=/.test(t) && !t.startsWith("eyJ")) return false; - try { - const padded = t + "=".repeat((4 - (t.length % 4)) % 4); - const json = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString( - "utf8" - ); - // Reject binary garbage that "decodes" but isn't JSON (corrupted sherlock paste). - if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(json)) return false; - const obj = JSON.parse(json) as { sid?: unknown; ftr?: unknown; ark?: unknown }; - return typeof obj.sid === "string" && obj.sid.length > 0; - } catch { - // Opaque short sherlockToken values (tests / non-JSON) when non-empty. - // No mid-string "=" (cookie pair leftovers); padding "=" at end is OK. - if (/=.+/.test(t.replace(/=+$/, ""))) return false; - return !looksLikeAdobeJwt(t) && /^[A-Za-z0-9+/_=-]+$/.test(t); - } -} - -/** - * Synthesize x-arp-session-id when no browser sherlockToken is available. - * Shape matches live successful generate (adobe/image_generate.txt): - * base64(JSON({sid, ark, bfp, ftr, fpjs})) - * ALWAYS send this header on generate-async / storage upload. - * Prefer real sherlockToken / cookie rebuild (forter+arkose+sid) when available. - */ -export function buildAdobeArpSessionId(region = "eu-west-1"): string { - const nowMs = Date.now(); - const sid = randomUUID(); - const randHex = randomBytes(16).toString("hex"); - // Live ftr: {32hex}_{ms}__UDF43-m4_31ck_{b64}=-N-v2_tt - const mid = randomBytes(12).toString("base64url"); - const n = 1000 + Math.floor(Math.random() * 9000); - const ftr = `${randHex}_${nowMs}${ADOBE_FIREFLY_FTR_MAGIC}_${mid}=-${n}-v2_tt`; - // Arkose session-shaped string (public pk from firefly SPA). Without a real - // Arkose solve this may still 408; real sherlockToken is the stable path. - const arkSession = `${randomBytes(8).toString("hex")}.${Math.random().toFixed(10).slice(2)}`; - const ark = - `${arkSession}|r=${region}|meta=3|metabgclr=transparent|metaiconclr=%23757575|` + - `guitextcolor=%23000000|pk=${ADOBE_FIREFLY_ARKOSE_PUBLIC_KEY}|at=40|sup=1|rid=13|ag=101|` + - `cdn_url=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc|` + - `surl=https%3A%2F%2Farks-client.adobe.com|` + - `smurl=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc%2Fassets%2Fstyle-manager`; - // Successful browser ARP also carries Browser Fingerprint + FingerprintJS payload. - const bfp = randomUUID(); - const fpjs = JSON.stringify({ - requestId: `${nowMs}.${randomBytes(3).toString("base64url")}`, - visitorId: randomBytes(12).toString("base64url"), - }); - const raw = JSON.stringify({ sid, ark, bfp, ftr, fpjs }); - return Buffer.from(raw, "utf-8").toString("base64"); -} - -/** - * Pull sherlockToken / x-arp-session-id from Cookie header, HAR paste, or multi-line credential. - * Browser generate sends Cookie.sherlockToken (or the request header) as x-arp-session-id. - * Live value is base64({sid, ark, ftr}) — includes Arkose session data. - * - * Also handles PasswordBox mangling (JWT + ARP joined by a single space) and full fetch() - * copy/paste from DevTools (browser network capture). - */ -export function extractAdobeArpSessionId(cookieOrBlob: string): string { - const raw = String(cookieOrBlob || ""); - if (!raw.trim()) return ""; - - const candidates: string[] = []; - const push = (v: string | undefined | null) => { - if (!v) return; - let t = v.trim().replace(/^["']|["']$/g, "").trim(); - try { - // Cookie values are often URI-encoded - if (/%[0-9A-Fa-f]{2}/.test(t)) t = decodeURIComponent(t); - } catch { - /* keep raw */ - } - if (t) candidates.push(t); - }; - - // Cookie: sherlockToken=... - const m = raw.match(/(?:^|[;\s\n\r])sherlockToken=([^;\s\n\r]+)/i); - if (m?.[1]) push(m[1]); - - // Cookie or form: x-arp-session-id=... - const m2 = raw.match(/(?:^|[;\s\n\r])x-arp-session-id=([^;\s\n\r]+)/i); - if (m2?.[1]) push(m2[1]); - - // HAR / Network / fetch() headers: "x-arp-session-id": "eyJ..." or x-arp-session-id: eyJ... - const m3 = raw.match(/["']?x-arp-session-id["']?\s*[:=]\s*["']?([A-Za-z0-9+/=_-]{40,})["']?/i); - if (m3?.[1]) push(m3[1]); - - // HAR: "sherlockToken": "eyJ..." - const m4 = raw.match(/["']?sherlockToken["']?\s*[:=]\s*["']?([A-Za-z0-9+/=_-]{40,})["']?/i); - if (m4?.[1]) push(m4[1]); - - // Bare base64 ARP blob on its own line (line 2 of two-line paste) - for (const line of raw.split(/[\r\n]+/)) { - const t = line.trim().replace(/^["']|["']$/g, ""); - // Skip pure JWT lines - if (looksLikeAdobeJwt(t)) continue; - if (t.length >= 40 && isValidAdobeArpSessionId(t)) push(t); - } - - // JWT + ARP joined by whitespace (single-line PasswordBox paste collapses \n → space) - // Split on whitespace only — NOT on "=" — so we never treat "aux_sid=…" as a token. - const withoutJwt = raw.replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, " "); - for (const token of withoutJwt.split(/[\s,;"']+/)) { - let t = token.trim(); - // If this chunk is name=value from a Cookie header, only keep the value when - // the name is sherlockToken / x-arp-session-id; skip aux_sid, forter, etc. - const eq = t.indexOf("="); - if (eq > 0 && eq < 40 && /^[A-Za-z0-9_.%-]+$/.test(t.slice(0, eq))) { - const name = t.slice(0, eq).toLowerCase(); - if (name === "sherlocktoken" || name === "x-arp-session-id") { - t = t.slice(eq + 1).trim(); - } else { - continue; - } - } - if (t.length >= 40 && isValidAdobeArpSessionId(t)) push(t); - } - - // Prefer ARP that decodes to JSON with sid+ark (real browser session over opaque short tokens) - const ranked = candidates - .map((c) => c.replace(/^["']|["']$/g, "").trim()) - .filter((v) => isValidAdobeArpSessionId(v)); - ranked.sort((a, b) => scoreAdobeArpCandidate(b) - scoreAdobeArpCandidate(a)); - return ranked[0] || ""; -} - -/** Higher = more like a live firefly-3p x-arp-session-id (sid+ark+ftr[+bfp+fpjs] base64). */ -function scoreAdobeArpCandidate(value: string): number { - let score = value.length; - try { - const padded = value + "=".repeat((4 - (value.length % 4)) % 4); - const json = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"); - const obj = JSON.parse(json) as { - sid?: unknown; - ark?: unknown; - ftr?: unknown; - bfp?: unknown; - fpjs?: unknown; - }; - if (typeof obj.sid === "string" && obj.sid) score += 1000; - if (typeof obj.ark === "string" && obj.ark.length > 20) score += 500; - if (typeof obj.ftr === "string" && obj.ftr.includes(ADOBE_FIREFLY_FTR_MAGIC)) score += 200; - if (typeof obj.ark === "string" && obj.ark.includes(ADOBE_FIREFLY_ARKOSE_PUBLIC_KEY)) score += 100; - // Live successful generates (adobe/image_generate.txt) include browser fingerprint fields. - if (typeof obj.bfp === "string" && obj.bfp.length >= 8) score += 150; - if (typeof obj.fpjs === "string" && obj.fpjs.length > 10) score += 150; - } catch { - /* opaque sherlockToken */ - } - return score; -} - -/** - * True when the credential blob already contains a browser ARP / sherlockToken - * OR enough cookie pieces to rebuild one (ff_session_guid + arkose + forterToken). - * Synthetic-only ARP is a fallback — real cookie pieces are required for stable generate. - */ -export function hasBrowserAdobeArpSession(sessionCookieOrBlob?: string): boolean { - const blob = String(sessionCookieOrBlob || ""); - if (extractAdobeArpSessionId(blob)) return true; - // Rebuild path counts as browser ARP (same pieces the SPA uses for sherlockToken). - const sid = blob.match(/(?:^|[;\s])ff_session_guid=([^;\s]+)/i)?.[1]; - const ark = blob.match(/(?:^|[;\s])arkose=([^;\s]+)/i)?.[1]; - const ftr = - blob.match(/(?:^|[;\s])forterToken=([^;\s]+)/i)?.[1] || - blob.match(/(?:^|[;\s])forter=([^;\s]+)/i)?.[1]; - return Boolean(sid && ark && ftr && !/^[a-f0-9]{32},\d+$/i.test(ftr)); -} - -/** - * Resolve ARP for a Firefly request. - * Prefer cookie rebuild (ff_session_guid + arkose + forterToken [+bfp/fpjs]) over a - * frozen sherlockToken paste — Forter advances while the pasted ARP goes stale. - * Fall back to sherlockToken / x-arp-session-id extract, then synthetic rich ARP. - * Mint once per generate/upload chain and reuse (browser uses the same ARP for upload+submit); - * on 408 the submit loop rotates ARP separately. - */ -export function resolveAdobeArpSessionId(sessionCookieOrBlob?: string): string { - const blob = String(sessionCookieOrBlob || ""); - // Lazy require of rebuild helper to avoid circular import at module load. - // Inline minimal rebuild here (sid+ark+ftr) so resolve stays self-contained. - const getCookie = (name: string): string => { - const m = blob.match( - new RegExp(`(?:^|[;\\s\\n\\r])${name}=([^;\\s\\n\\r]+)`, "i") - ); - if (!m?.[1]) return ""; - let v = m[1].trim(); - try { - if (/%[0-9A-Fa-f]{2}/.test(v)) v = decodeURIComponent(v); - } catch { - /* keep */ - } - return v; - }; - const sid = getCookie("ff_session_guid"); - const ark = getCookie("arkose"); - let ftr = getCookie("forterToken") || getCookie("forter"); - try { - if (/%[0-9A-Fa-f]{2}/.test(ftr)) ftr = decodeURIComponent(ftr); - } catch { - /* keep */ - } - if (ftr.endsWith("v2") && !ftr.endsWith("v2_tt")) ftr = `${ftr}_tt`; - // Skip localStorage-style "id,timestamp" forter values - if (/^[a-f0-9]{32},\d+$/i.test(ftr)) ftr = ""; - if (sid && ark && ftr) { - const bfp = getCookie("bfp"); - let fpjs = getCookie("fpjs"); - try { - if (fpjs && /%[0-9A-Fa-f]{2}/.test(fpjs)) fpjs = decodeURIComponent(fpjs); - } catch { - /* keep */ - } - const obj: Record = { sid, ark, ftr }; - if (bfp) obj.bfp = bfp; - if (fpjs) obj.fpjs = fpjs; - return Buffer.from(JSON.stringify(obj), "utf-8").toString("base64"); - } - const extracted = extractAdobeArpSessionId(blob); - if (extracted) return extracted; - return buildAdobeArpSessionId(); -} - -export function buildAdobeSubmitHeaders( - accessToken: string, - extras?: { - arpSessionId?: string; - nonce?: string; - cookie?: string; - /** Required for deterministic x-nonce (sha256 user_id+prompt). */ - prompt?: string; - } -): Record { - // Live capture (browser network capture) + working clients: - // Authorization + x-api-key + x-nonce + ALWAYS x-arp-session-id (sid+ark+ftr). - // Do NOT attach firefly.adobe.com page Cookie to firefly-3p (wrong origin). - // Prefer real sherlockToken from cookie blob; synthetic ARP is fallback only. - const cookieBlob = String(extras?.cookie || "").trim(); - const deterministic = - extras?.nonce || - (extras?.prompt ? buildAdobeSubmitNonce(accessToken, extras.prompt) : "") || - generateAdobeNonce(); - // Explicit arpSessionId wins (caller may pass synthetic short test ids or real browser ARP). - const explicitArp = extras?.arpSessionId ? String(extras.arpSessionId).trim() : ""; - const arp = - explicitArp || - extractAdobeArpSessionId(cookieBlob) || - buildAdobeArpSessionId(); - const headers: Record = { - ...browserHeaders(), - Authorization: `Bearer ${accessToken}`, - // Must be clio-playground-web — same client_id that minted the IMS token. - "x-api-key": adobeFireflyApiKey(), - "content-type": "application/json", - accept: "*/*", - "cache-control": "no-cache", - pragma: "no-cache", - priority: "u=1, i", - "x-nonce": deterministic, - "x-arp-session-id": arp, - }; - return headers; -} - -/** Max reference image size for Firefly storage upload (20 MiB). */ -export const ADOBE_FIREFLY_MAX_UPLOAD_BYTES = 20 * 1024 * 1024; - -/** - * Headers for POST /v2/storage/image (raw image body). - * Live capture (browser network capture): Bearer + x-api-key + x-arp + x-nonce - * + content-type image/png|jpeg (not application/json). - */ -export function buildAdobeUploadHeaders( - accessToken: string, - contentType: string, - extras?: { - arpSessionId?: string; - nonce?: string; - cookie?: string; - prompt?: string; - } -): Record { - const base = buildAdobeSubmitHeaders(accessToken, { - arpSessionId: extras?.arpSessionId, - nonce: extras?.nonce, - cookie: extras?.cookie, - prompt: extras?.prompt || "upload", - }); - const ct = String(contentType || "image/png").trim().toLowerCase() || "image/png"; - return { - ...base, - "content-type": ct.startsWith("image/") ? ct : "image/png", - }; -} - -/** - * Collect reference image sources from an OpenAI-style / Media-page image|video body. - * Supports: image_url, image, images[], image_urls[], input_image(s), reference_images, - * provider_options.*, and prompt_image fields used by the WinUI Media page. - */ -export function extractAdobeSourceImageSources(body: unknown, max = 4): string[] { - if (!body || typeof body !== "object") return []; - const b = body as Record; - const po = - b.provider_options && typeof b.provider_options === "object" && !Array.isArray(b.provider_options) - ? (b.provider_options as Record) - : {}; - - const out: string[] = []; - const seen = new Set(); - const push = (v: unknown) => { - if (out.length >= max) return; - if (typeof v === "string") { - const t = v.trim(); - if (!t || seen.has(t)) return; - // Skip empty / clearly non-image - if (t === "null" || t === "undefined") return; - seen.add(t); - out.push(t); - return; - } - if (Array.isArray(v)) { - for (const item of v) { - if (out.length >= max) break; - push(item); - } - return; - } - if (v && typeof v === "object") { - const o = v as Record; - if (typeof o.url === "string") push(o.url); - else if (typeof o.image_url === "string") push(o.image_url); - else if (o.image_url && typeof o.image_url === "object") { - const inner = (o.image_url as Record).url; - if (typeof inner === "string") push(inner); - } else if (typeof o.b64_json === "string") { - push(`data:image/png;base64,${o.b64_json}`); - } else if (typeof o.base64 === "string") { - push(`data:image/png;base64,${o.base64}`); - } - } - }; - - // Order matches MediaViewModel / OpenAI edit aliases (primary single fields first). - const keys = [ - "image_url", - "imageUrl", - "input_image", - "source_image", - "promptImage", - "prompt_image", - "image", - "images", - "image_urls", - "imageUrls", - "input_images", - "reference_images", - "referenceImages", - "reference_image", - ]; - for (const k of keys) { - push(b[k]); - push(po[k]); - } - - // OpenAI chat-style content parts (rare on /v1/images but harmless). - if (Array.isArray(b.messages)) { - for (const msg of b.messages) { - if (!msg || typeof msg !== "object") continue; - const content = (msg as Record).content; - if (!Array.isArray(content)) continue; - for (const part of content) { - if (!part || typeof part !== "object") continue; - const p = part as Record; - if (p.type === "image_url" || p.type === "image") { - push(p.image_url ?? p.image ?? p.url); - } - } - } - } - - return out.slice(0, max); -} - -export function parseAdobeImageSourceBytes(source: string): { - buffer: Buffer; - contentType: string; -} { - const trimmed = String(source || "").trim(); - if (!trimmed) { - throw new AdobeFireflyError("Empty image reference", 400, "bad_image"); - } - - const dataUri = /^data:([^;,]+)?(?:;charset=[^;,]+)?(;base64)?,([\s\S]+)$/i.exec(trimmed); - if (dataUri) { - const mime = (dataUri[1] || "image/png").trim().toLowerCase() || "image/png"; - const isB64 = Boolean(dataUri[2]); - const payload = dataUri[3] || ""; - if (!isB64) { - throw new AdobeFireflyError( - "Image data URL must be base64-encoded (data:image/...;base64,...)", - 400, - "bad_image" - ); - } - const buffer = Buffer.from(payload.replace(/\s/g, ""), "base64"); - if (!buffer.length) { - throw new AdobeFireflyError("Image data URL decoded to empty bytes", 400, "bad_image"); - } - if (buffer.length > ADOBE_FIREFLY_MAX_UPLOAD_BYTES) { - throw new AdobeFireflyError( - `Image reference too large (${buffer.length} bytes; max ${ADOBE_FIREFLY_MAX_UPLOAD_BYTES})`, - 400, - "bad_image" - ); - } - return { buffer, contentType: mime.startsWith("image/") ? mime : "image/png" }; - } - - // Raw base64 without data: prefix - if (!/^https?:\/\//i.test(trimmed) && /^[A-Za-z0-9+/=\s]+$/.test(trimmed) && trimmed.length > 64) { - const buffer = Buffer.from(trimmed.replace(/\s/g, ""), "base64"); - if (buffer.length > 0 && buffer.length <= ADOBE_FIREFLY_MAX_UPLOAD_BYTES) { - return { buffer, contentType: "image/png" }; - } - } - - throw new AdobeFireflyError( - "Unsupported image reference (need data:image/...;base64,... or raw base64). " + - "HTTP(S) URLs are resolved by the caller before upload.", - 400, - "bad_image" - ); -} - -/** - * Parse Firefly storage upload response: {"images":[{"id":"uuid"}]}. - */ -export function parseAdobeStorageUploadResponse(body: unknown): string { - if (!body || typeof body !== "object") return ""; - const images = (body as Record).images; - if (Array.isArray(images) && images.length > 0) { - const first = images[0]; - if (first && typeof first === "object") { - const id = (first as Record).id; - if (typeof id === "string" && id.trim()) return id.trim(); - } - } - const id = (body as Record).id; - if (typeof id === "string" && id.trim()) return id.trim(); - return ""; -} - -/** - * Upload one image to Firefly storage → blob id for referenceBlobs. - * Wire: POST https://firefly-3p.ff.adobe.io/v2/storage/image (raw bytes). - */ -export async function uploadAdobeFireflyImage(opts: { - accessToken: string; - bytes: Buffer | Uint8Array; - contentType?: string; - sessionCookie?: string; - /** Reuse the same ARP as generate-async (browser does). */ - arpSessionId?: string; - /** Used for deterministic x-nonce (optional). */ - prompt?: string; - fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; -}): Promise { - const fetchImpl = opts.fetchImpl || fetch; - const buffer = Buffer.isBuffer(opts.bytes) ? opts.bytes : Buffer.from(opts.bytes); - if (!buffer.length) { - throw new AdobeFireflyError("Cannot upload empty image", 400, "bad_image"); - } - if (buffer.length > ADOBE_FIREFLY_MAX_UPLOAD_BYTES) { - throw new AdobeFireflyError( - `Image reference too large (${buffer.length} bytes; max ${ADOBE_FIREFLY_MAX_UPLOAD_BYTES})`, - 400, - "bad_image" - ); - } - - const sessionCookie = String(opts.sessionCookie || "").trim(); - const cookieHeader = extractAdobeCookieHeader(sessionCookie); - // One ARP for the whole chain — do not mint a new synthetic id per upload. - const arpSessionId = - (opts.arpSessionId && String(opts.arpSessionId).trim()) || - resolveAdobeArpSessionId(cookieHeader || sessionCookie); - const contentType = - (opts.contentType && opts.contentType.trim()) || - (buffer[0] === 0xff && buffer[1] === 0xd8 - ? "image/jpeg" - : buffer[0] === 0x89 && buffer[1] === 0x50 - ? "image/png" - : "image/png"); - - const resp = await fetchImpl(ADOBE_FIREFLY_IMAGE_UPLOAD_URL, { - method: "POST", - headers: buildAdobeUploadHeaders(opts.accessToken, contentType, { - arpSessionId, - cookie: cookieHeader || undefined, - prompt: opts.prompt || "upload", - }), - body: buffer, - }); - - const text = await resp.text().catch(() => ""); - if (resp.status === 401 || resp.status === 403) { - throw new AdobeFireflyError( - "Adobe Firefly image upload unauthorized — paste a fresh IMS JWT", - 401, - "auth" - ); - } - if (!resp.ok) { - throw new AdobeFireflyError( - `Adobe Firefly image upload failed (${resp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`, - resp.status >= 400 && resp.status < 500 ? resp.status : 502, - "upload" - ); - } - - let json: unknown = {}; - try { - json = text ? JSON.parse(text) : {}; - } catch { - throw new AdobeFireflyError( - "Adobe Firefly image upload returned non-JSON body", - 502, - "upload" - ); - } - const id = parseAdobeStorageUploadResponse(json); - if (!id) { - throw new AdobeFireflyError( - "Adobe Firefly image upload succeeded but no images[].id was returned", - 502, - "upload" - ); - } - opts.log?.info?.("ADOBE-FIREFLY", `uploaded reference image id=${id} (${buffer.length} bytes)`); - return id; -} - -/** - * Resolve Media/OpenAI body image fields → Firefly storage blob ids. - * - data: URLs / raw base64 → upload - * - http(s) URLs → fetch then upload - * - already looks like a UUID blob id → use as-is (advanced) - */ -export async function resolveAdobeSourceImageIds(opts: { - accessToken: string; - body: unknown; - max?: number; - sessionCookie?: string; - /** Shared ARP for upload+generate (required for stable Firefly 3P). */ - arpSessionId?: string; - prompt?: string; - fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; -}): Promise { - const max = Math.max(1, Math.min(8, opts.max ?? 4)); - const sources = extractAdobeSourceImageSources(opts.body, max); - if (!sources.length) return []; - - const fetchImpl = opts.fetchImpl || fetch; - const ids: string[] = []; - // One ARP for all uploads in this request (browser reuses the same header). - const arpSessionId = - (opts.arpSessionId && String(opts.arpSessionId).trim()) || - resolveAdobeArpSessionId(opts.sessionCookie); - - for (const src of sources) { - // Already a Firefly storage id (uuid) - if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(src)) { - ids.push(src); - continue; - } - - let buffer: Buffer; - let contentType = "image/png"; - - if (/^https?:\/\//i.test(src)) { - const r = await fetchImpl(src, { - method: "GET", - headers: { accept: "image/*,*/*" }, - }); - if (!r.ok) { - throw new AdobeFireflyError( - `Failed to download reference image (${r.status}): ${src.slice(0, 120)}`, - 400, - "bad_image" - ); - } - const ab = await r.arrayBuffer(); - buffer = Buffer.from(ab); - const ct = r.headers.get("content-type") || ""; - if (ct.toLowerCase().startsWith("image/")) { - contentType = ct.split(";")[0]!.trim(); - } - } else { - const parsed = parseAdobeImageSourceBytes(src); - buffer = parsed.buffer; - contentType = parsed.contentType; - } - - const id = await uploadAdobeFireflyImage({ - accessToken: opts.accessToken, - bytes: buffer, - contentType, - sessionCookie: opts.sessionCookie, - arpSessionId, - prompt: opts.prompt, - fetchImpl, - log: opts.log, - }); - ids.push(id); - } - - return ids; -} - -/** Transient Adobe 3P overload / rate / edge errors worth retrying. */ -export function isAdobeTransientSubmitError(status: number, bodyText: string): boolean { - if (status === 408 || status === 429 || status === 502 || status === 503 || status === 504) { - return true; - } - const t = (bodyText || "").toLowerCase(); - return ( - t.includes("timeout_error") || - t.includes("system under load") || - t.includes("try again") || - t.includes("temporarily") || - t.includes("overloaded") - ); -} - -export function buildAdobePollHeaders(accessToken: string): Record { - // Live adobe/status_check.txt: Bearer + accept only (no x-api-key, no Cookie). - return { - Authorization: `Bearer ${accessToken}`, - accept: "*/*", - "cache-control": "no-cache", - pragma: "no-cache", - "user-agent": DEFAULT_USER_AGENT, - referer: FIREFLY_REFERER, - }; -} - -export function buildAdobeBalanceHeaders(accessToken: string): Record { - const accountId = extractAdobeAccountIdFromToken(accessToken); - const headers: Record = { - ...browserHeaders(), - Authorization: `Bearer ${accessToken}`, - accept: "application/json", - "content-type": "application/json", - "x-api-key": adobeFireflyBalanceApiKey(), - }; - if (accountId) headers["x-account-id"] = accountId; - return headers; -} - -export function buildAdobeDiscoveryHeaders(accessToken: string): Record { - return { - ...browserHeaders(), - Authorization: `Bearer ${accessToken}`, - "x-api-key": adobeFireflyApiKey(), - "content-type": "application/json", - // Missing Accept → HTTP 406 "Unsupported Accept Type or not allowed". - accept: "*/*", - }; -} - -/** User-facing message when Adobe colligo returns 408 "system under load". */ -export function formatAdobeSystemUnderLoadError( - kind: "image" | "video", - attempts: number, - opts?: { hadBrowserArp?: boolean } -): string { - const hadArp = opts?.hadBrowserArp === true; - if (!hadArp) { - return ( - `Adobe Firefly ${kind} generation failed (HTTP 408 "system under load", after ${attempts} attempt` + - `${attempts === 1 ? "" : "s"}). Your credential is missing a browser x-arp-session-id / sherlockToken ` + - `(JWT alone almost always 408s even when credits/Limits work). Re-open the Adobe Firefly account and paste ` + - `TWO lines from a SUCCESSFUL firefly-3p.ff.adobe.io generate-async request (F12 → Network): ` + - `(1) Authorization token AFTER "Bearer " (eyJ… JWT), (2) the raw x-arp-session-id header value ` + - `OR Cookie containing sherlockToken. Use the multi-line credential box so both lines are kept.` - ); - } - return ( - `Adobe Firefly ${kind} generation failed (HTTP 408 "system under load", after ${attempts} attempt` + - `${attempts === 1 ? "" : "s"}). JWT was accepted for balance/discovery but colligo rejected the risk session ` + - `(Forter/Arkose). The app auto-rebuilds x-arp-session-id from Cookie (forterToken+arkose+ff_session_guid) and ` + - `rotates ARP on each retry — paste the full firefly.adobe.com Cookie once alongside the JWT so refresh can ` + - `run automatically. If this keeps failing, open firefly.adobe.com, generate one image in-browser, then paste ` + - `a FRESH multi-line credential (JWT + Cookie) once; subsequent generates should not need re-paste.` - ); -} - -export function extractAdobeResultLink( - headers: Headers | Record, - body: unknown -): string { - const get = (name: string): string => { - if (typeof (headers as Headers).get === "function") { - return String((headers as Headers).get(name) || "").trim(); - } - const rec = headers as Record; - const key = Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase()); - return String((key ? rec[key] : "") || "").trim(); - }; - - const override = get("x-override-status-link"); - if (override) return override; - - const data = body && typeof body === "object" ? (body as Record) : {}; - const links = data.links && typeof data.links === "object" ? (data.links as Record) : {}; - const result = links.result; - if (typeof result === "string" && result) return result; - if (result && typeof result === "object") { - const href = (result as Record).href; - if (typeof href === "string" && href) return href; - } - if (typeof data.statusUrl === "string" && data.statusUrl) return data.statusUrl; - if (typeof data.resultUrl === "string" && data.resultUrl) return data.resultUrl; - return ""; -} - -/** - * Rewrite Firefly EPO result links to the BKS poll endpoint used by the SPA. - * - * Live capture (adobe/status_check.txt): - * links.result = https://firefly-epo855232.adobe.io/jobs/result/{jobId} - * poll URL = https://bks-epo8552.adobe.io/v2/jobs/result/{jobId}?host=firefly-epo855232.adobe.io - * - * BKS host uses the first 4 digits of the EPO id when the id is longer (855232 → 8552). - */ -export function normalizeAdobePollUrl(rawUrl: string): string { - const url = String(rawUrl || "").trim(); - if (!url) return url; - try { - const parsed = new URL(url); - const host = parsed.hostname.toLowerCase(); - if (!host.startsWith("firefly-epo")) return url; - - const path = parsed.pathname || ""; - const isJobPath = - path.includes("/jobs/result/") || - path.includes("/v2/status") || - path.includes("/status/"); - if (!isJobPath) return url; - - const jobId = path.split("/").filter(Boolean).pop() || ""; - if (!jobId || jobId === "status" || jobId === "result") return url; - - const epoId = host.slice("firefly-epo".length).split(".")[0] || ""; - // 855232 → 8552 (browser BKS host); short ids kept as-is. - const bksId = epoId.length > 4 ? epoId.slice(0, 4) : epoId; - return `https://bks-epo${bksId}.adobe.io/v2/jobs/result/${jobId}?host=${host}`; - } catch { - return url; - } -} - -export function extractAdobeMediaUrl( - latest: unknown, - kind: "image" | "video" -): string | null { - const body = latest && typeof latest === "object" ? (latest as Record) : {}; - const outputs = Array.isArray(body.outputs) ? body.outputs : []; - if (outputs.length > 0) { - const first = outputs[0] && typeof outputs[0] === "object" ? (outputs[0] as Record) : {}; - const media = - kind === "image" - ? first.image && typeof first.image === "object" - ? (first.image as Record) - : null - : first.video && typeof first.video === "object" - ? (first.video as Record) - : null; - const url = media && typeof media.presignedUrl === "string" ? media.presignedUrl : null; - if (url) return url; - } - - // Fallback recursive search for a presigned URL. - const found = findPresignedUrl(latest, kind === "image" ? [".png", ".jpg", ".jpeg", ".webp"] : [".mp4", ".webm"]); - return found; -} - -function findPresignedUrl(obj: unknown, exts: string[]): string | null { - if (!obj) return null; - if (typeof obj === "string") { - const s = obj.trim(); - if (/^https?:\/\//i.test(s) && (exts.some((e) => s.toLowerCase().includes(e)) || s.includes("presigned") || s.includes("X-Amz"))) { - return s; - } - return null; - } - if (Array.isArray(obj)) { - for (const item of obj) { - const found = findPresignedUrl(item, exts); - if (found) return found; - } - return null; - } - if (typeof obj === "object") { - const rec = obj as Record; - if (typeof rec.presignedUrl === "string" && rec.presignedUrl) return rec.presignedUrl; - for (const value of Object.values(rec)) { - const found = findPresignedUrl(value, exts); - if (found) return found; - } - } - return null; -} - -export function isAdobeJobInProgress(status: string): boolean { - const s = String(status || "").toUpperCase(); - return ( - !s || - s === "IN_PROGRESS" || - s === "PENDING" || - s === "RUNNING" || - s === "QUEUED" || - s === "PROCESSING" || - s === "SUBMITTED" - ); -} - -export function isAdobeJobFailed(status: string): boolean { - const s = String(status || "").toUpperCase(); - return s === "FAILED" || s === "CANCELLED" || s === "ERROR" || s === "CANCELED"; -} - -type ImsTokenResponse = { - access_token?: string; - account_type?: string; - guestId?: string; - token_type?: string; - error?: string; - error_description?: string; -}; - -async function imsCheckToken(opts: { - cookie: string; - clientId: string; - guestAllowed: boolean; - fetchImpl: typeof fetch; -}): Promise< - | { ok: true; token: string; data: ImsTokenResponse } - | { ok: false; status: number; error: string } -> { - const form = new URLSearchParams({ - client_id: opts.clientId, - scope: ADOBE_FIREFLY_IMS_SCOPE, - guest_allowed: opts.guestAllowed ? "true" : "false", - }); - - const resp = await opts.fetchImpl(ADOBE_FIREFLY_IMS_REFRESH_URL, { - method: "POST", - headers: { - Accept: "*/*", - "Accept-Language": "en-US,en;q=0.9", - "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", - Cookie: opts.cookie, - Origin: FIREFLY_ORIGIN, - Referer: FIREFLY_REFERER, - "User-Agent": DEFAULT_USER_AGENT, - }, - body: form.toString(), - }); - - const text = await resp.text().catch(() => ""); - let data: ImsTokenResponse | null = null; - try { - data = JSON.parse(text) as ImsTokenResponse; - } catch { - data = null; - } - - if (!resp.ok) { - return { - ok: false, - status: resp.status, - error: sanitizeErrorMessage( - data?.error_description || data?.error || text.slice(0, 200) || `HTTP ${resp.status}` - ), - }; - } - - const token = String(data?.access_token || "").trim(); - if (!token) { - return { - ok: false, - status: 401, - error: sanitizeErrorMessage( - data?.error_description || data?.error || "IMS response missing access_token" - ), - }; - } - return { ok: true, token, data: data || {} }; -} - -/** - * Exchange a browser Cookie header for an Adobe IMS **user** access_token. - * - * Live repro (user firefly.adobe.com Cookie export): - * - guest_allowed=true → account_type=guest (no AdobeID) → generate 401 / balance 403 - * - guest_allowed=false → "All session cookies are empty" (IMS cookies live on adobelogin.com) - * - * Reliable path: paste Authorization Bearer JWT from a live firefly-3p request. - */ -export async function exchangeAdobeCookieForAccessToken( - cookieHeader: string, - fetchImpl: typeof fetch = fetch -): Promise { - const cookie = String(cookieHeader || "").trim(); - if (!cookie) { - throw new AdobeFireflyError("Adobe Firefly cookie is empty", 401, "missing_cookie"); - } - - // HAR / mixed paste that already contains a user JWT - const embedded = extractAdobeCredentialToken(cookie); - if (embedded !== cookie && looksLikeAdobeJwt(embedded)) { - if (isAdobeGuestAccessToken(embedded)) { - throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token"); - } - return embedded; - } - - const clientIds = [adobeFireflyApiKey(), adobeFireflyExpressClientId()].filter( - (id, i, arr) => id && arr.indexOf(id) === i - ); - - let sawEmptySession = false; - let lastError = ""; - let lastStatus = 502; - let guestTokenSeen = false; - - for (const clientId of clientIds) { - // 1) Authenticated session only (needs IMS cookies from adobelogin.com) - const authed = await imsCheckToken({ - cookie, - clientId, - guestAllowed: false, - fetchImpl, - }); - if (authed.ok) { - if ( - isAdobeGuestAccessToken(authed.token) || - authed.data.account_type === "guest" || - authed.data.guestId - ) { - guestTokenSeen = true; - } else { - return authed.token; - } - } else { - lastStatus = authed.status; - lastError = authed.error; - if (/session cookies are empty/i.test(authed.error)) sawEmptySession = true; - } - - // 2) Guest path — never accept guest tokens for Firefly media/limits - const guest = await imsCheckToken({ - cookie, - clientId, - guestAllowed: true, - fetchImpl, - }); - if (guest.ok) { - if ( - guest.data.account_type === "guest" || - guest.data.guestId || - isAdobeGuestAccessToken(guest.token) - ) { - guestTokenSeen = true; - lastError = "IMS returned a guest token (no AdobeID session)"; - lastStatus = 401; - continue; - } - return guest.token; - } - lastStatus = guest.status; - lastError = guest.error; - if (/session cookies are empty/i.test(guest.error)) sawEmptySession = true; - } - - if (guestTokenSeen || sawEmptySession) { - throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token"); - } - - throw new AdobeFireflyError( - `Adobe IMS token exchange failed (${lastStatus}): ${lastError || "no access_token"}. ${GUEST_COOKIE_HELP}`, - lastStatus === 401 || lastStatus === 403 ? 401 : 502, - "ims_refresh_failed" - ); -} - -/** - * Resolve credentials into a usable **user** IMS access token (rejects guest tokens). - */ -export async function resolveAdobeAccessToken( - credentials: - | { - apiKey?: string; - accessToken?: string; - providerSpecificData?: { cookie?: unknown; access_token?: unknown; accessToken?: unknown } | null; - } - | null - | undefined, - fetchImpl: typeof fetch = fetch -): Promise { - const psd = credentials?.providerSpecificData; - const candidates: string[] = []; - const push = (v: unknown) => { - if (typeof v === "string" && v.trim()) candidates.push(v.trim()); - }; - push(credentials?.apiKey); - push(credentials?.accessToken); - push(psd?.access_token); - push(psd?.accessToken); - push(psd?.cookie); - - if (candidates.length === 0) { - throw new AdobeFireflyError( - "Adobe Firefly credentials missing. " + GUEST_COOKIE_HELP, - 401, - "missing_credentials" - ); - } - - for (const c of candidates) { - const extracted = extractAdobeCredentialToken(c); - if (looksLikeAdobeJwt(extracted) && isAdobeUserAccessToken(extracted)) { - return extracted; - } - } - - for (const c of candidates) { - const extracted = extractAdobeCredentialToken(c); - if (looksLikeAdobeJwt(extracted) && isAdobeGuestAccessToken(extracted)) { - throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token"); - } - } - - const cookieBlob = - candidates.find( - (c) => - c.includes(";") || - c.toLowerCase().includes("aux_sid") || - c.toLowerCase().includes("ff_session") - ) || candidates[0]; - - const token = await exchangeAdobeCookieForAccessToken(cookieBlob, fetchImpl); - if (isAdobeGuestAccessToken(token)) { - throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token"); - } - return token; -} - -// ── Credits balance (Limits) ──────────────────────────────────────────────── - -export interface AdobeFireflyCreditsBalance { - total: number; - used: number; - remaining: number; - availableUntil: string | null; - freeTotal: number; - freeUsed: number; - freeRemaining: number; - planTotal: number; - planUsed: number; - planRemaining: number; - raw?: unknown; -} - -function readQuotaBlock(block: unknown): { total: number; used: number; available: number } { - if (!block || typeof block !== "object") return { total: 0, used: 0, available: 0 }; - const q = - (block as Record).quota && - typeof (block as Record).quota === "object" - ? ((block as Record).quota as Record) - : (block as Record); - const total = Number(q.total ?? 0); - const used = Number(q.used ?? 0); - const available = Number(q.available ?? Math.max(0, total - used)); - return { - total: Number.isFinite(total) ? total : 0, - used: Number.isFinite(used) ? used : 0, - available: Number.isFinite(available) ? available : 0, - }; -} - -/** - * Parse GET /v1/credits/balance JSON (adobe/balance.txt Response). - * total.quota = aggregate; credits.firefly_* = free + plan buckets. - */ -export function parseAdobeCreditsBalance(body: unknown): AdobeFireflyCreditsBalance { - const root = body && typeof body === "object" ? (body as Record) : {}; - const totalBlock = readQuotaBlock(root.total); - const credits = - root.credits && typeof root.credits === "object" - ? (root.credits as Record) - : {}; - const free = readQuotaBlock(credits.firefly_free_credit); - const plan = readQuotaBlock(credits.firefly_plan_credit); - - // Prefer top-level total; fall back to free+plan sum when total missing. - let total = totalBlock.total; - let used = totalBlock.used; - let remaining = totalBlock.available; - if (total <= 0 && (free.total > 0 || plan.total > 0)) { - total = free.total + plan.total; - used = free.used + plan.used; - remaining = free.available + plan.available; - } - if (remaining <= 0 && total > 0) remaining = Math.max(0, total - used); - - const availableUntil = - root.total && - typeof root.total === "object" && - typeof (root.total as Record).availableUntil === "string" - ? String((root.total as Record).availableUntil) - : null; - - return { - total, - used, - remaining, - availableUntil, - freeTotal: free.total, - freeUsed: free.used, - freeRemaining: free.available, - planTotal: plan.total, - planUsed: plan.used, - planRemaining: plan.available, - raw: body, - }; -} - -export async function fetchAdobeCreditsBalance( - accessToken: string, - fetchImpl: typeof fetch = fetch -): Promise { - const resp = await fetchImpl(ADOBE_FIREFLY_CREDITS_BALANCE_URL, { - method: "GET", - headers: buildAdobeBalanceHeaders(accessToken), - }); - if (resp.status === 401 || resp.status === 403) { - throw new AdobeFireflyError("Adobe Firefly balance: token invalid or expired", 401, "auth"); - } - if (!resp.ok) { - const text = await resp.text().catch(() => ""); - throw new AdobeFireflyError( - `Adobe Firefly balance failed (${resp.status}): ${sanitizeErrorMessage(text.slice(0, 200))}`, - 502 - ); - } - const data = await resp.json().catch(() => ({})); - return parseAdobeCreditsBalance(data); -} - -// ── Models discovery ──────────────────────────────────────────────────────── - -export interface AdobeFireflyDiscoveredModel { - modelId: string; - modelVersion: string; - displayName: string; - modality: "image" | "video" | "audio" | "unknown"; - enabled: boolean; - healthStatus?: string; -} - -/** - * Parse POST /v2/models/discovery response into flat model/version rows. - */ -export function parseAdobeModelsDiscovery(body: unknown): AdobeFireflyDiscoveredModel[] { - const root = body && typeof body === "object" ? (body as Record) : {}; - const models = Array.isArray(root.models) ? root.models : []; - const out: AdobeFireflyDiscoveredModel[] = []; - - for (const m of models) { - if (!m || typeof m !== "object") continue; - const rec = m as Record; - const modelId = String(rec.modelId || "").trim(); - if (!modelId) continue; - const versions = - rec.modelVersions && typeof rec.modelVersions === "object" - ? (rec.modelVersions as Record) - : {}; - for (const [ver, spec] of Object.entries(versions)) { - if (!spec || typeof spec !== "object") continue; - const s = spec as Record; - if (s.enabled === false) continue; - const mods = Array.isArray(s.outputModality) - ? s.outputModality.map((x) => String(x).toLowerCase()) - : []; - let modality: AdobeFireflyDiscoveredModel["modality"] = "unknown"; - if (mods.includes("image")) modality = "image"; - else if (mods.includes("video")) modality = "video"; - else if (mods.includes("audio")) modality = "audio"; - out.push({ - modelId, - modelVersion: ver, - displayName: String(s.modelDisplayName || s.modelCaiDisplayName || ver), - modality, - enabled: s.enabled !== false, - healthStatus: typeof s.healthStatus === "string" ? s.healthStatus : undefined, - }); - } - } - return out; -} - -export async function discoverAdobeFireflyModels( - accessToken: string, - fetchImpl: typeof fetch = fetch -): Promise { - const resp = await fetchImpl(ADOBE_FIREFLY_MODELS_DISCOVERY_URL, { - method: "POST", - headers: buildAdobeDiscoveryHeaders(accessToken), - body: JSON.stringify({ filters: { resolveSchema: true } }), - }); - if (resp.status === 401 || resp.status === 403) { - throw new AdobeFireflyError("Adobe Firefly model discovery: token invalid or expired", 401, "auth"); - } - if (!resp.ok) { - const text = await resp.text().catch(() => ""); - throw new AdobeFireflyError( - `Adobe Firefly model discovery failed (${resp.status}): ${sanitizeErrorMessage(text.slice(0, 200))}`, - 502 - ); - } - const data = await resp.json().catch(() => ({})); - return parseAdobeModelsDiscovery(data); -} - -async function sleep(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function pollAdobeJob(opts: { - pollUrl: string; - accessToken: string; - kind: "image" | "video"; - timeoutMs: number; - pollIntervalMs?: number; - fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; -}): Promise<{ mediaUrl: string; latest: unknown }> { - const fetchImpl = opts.fetchImpl || fetch; - const deadline = Date.now() + opts.timeoutMs; - const interval = opts.pollIntervalMs && opts.pollIntervalMs > 0 ? opts.pollIntervalMs : DEFAULT_POLL_INTERVAL_MS; - let attempt = 0; - let latest: unknown = {}; - - while (Date.now() < deadline) { - attempt += 1; - const pollResp = await fetchImpl(opts.pollUrl, { - method: "GET", - headers: buildAdobePollHeaders(opts.accessToken), - }); - - if (pollResp.status === 401 || pollResp.status === 403) { - const accessError = pollResp.headers.get("x-access-error") || ""; - if (accessError === "taste_exhausted") { - throw new AdobeFireflyError("Adobe Firefly quota exhausted for this account", 429, "quota_exhausted"); - } - throw new AdobeFireflyError("Adobe Firefly token invalid or expired", 401, "auth"); - } - - if (!pollResp.ok) { - const text = await pollResp.text().catch(() => ""); - if ( - pollResp.status === 408 || - pollResp.status === 429 || - pollResp.status === 451 || - pollResp.status >= 500 || - isAdobeTransientSubmitError(pollResp.status, text) - ) { - opts.log?.info?.("ADOBE-FIREFLY", `poll temporary ${pollResp.status}, attempt #${attempt}`); - await sleep(interval); - continue; - } - throw new AdobeFireflyError( - `Adobe Firefly poll failed (${pollResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`, - 502 - ); - } - - latest = await pollResp.json().catch(() => ({})); - const statusHeader = String(pollResp.headers.get("x-task-status") || "").toUpperCase(); - const statusVal = String( - (latest && typeof latest === "object" ? (latest as Record).status : "") || - statusHeader || - "" - ).toUpperCase(); - - const mediaUrl = extractAdobeMediaUrl(latest, opts.kind); - if (mediaUrl) { - return { mediaUrl, latest }; - } - - if (isAdobeJobFailed(statusVal)) { - throw new AdobeFireflyError( - `Adobe Firefly ${opts.kind} job failed: ${sanitizeErrorMessage(JSON.stringify(latest).slice(0, 300))}`, - 502, - "job_failed" - ); - } - - opts.log?.info?.("ADOBE-FIREFLY", `${opts.kind} pending #${attempt} status=${statusVal || "unknown"}`); - await sleep(interval); - } - - throw new AdobeFireflyError(`Adobe Firefly ${opts.kind} generation timed out`, 504, "timeout"); -} - -// Colligo often returns instant 408 with x-colligo-timeout:0.0 under load. -// Keep retries short: hammering Adobe with 8 long waits makes the Media page -// look broken while balance still works. SPA succeeds on a healthy queue/token. -const SUBMIT_MAX_ATTEMPTS = 4; -const SUBMIT_BASE_DELAY_MS = 1200; - -export async function adobeFireflyGenerateImage(opts: { - accessToken: string; - prompt: string; - model: string; - size?: unknown; - aspectRatio?: unknown; - quality?: unknown; - seed?: number; - sourceImageIds?: string[]; - negativePrompt?: string; - /** Optional Cookie blob — used only to lift sherlockToken → x-arp-session-id */ - sessionCookie?: string; - /** Shared ARP (sid+ark+ftr). Reuse with uploads; do not mint per retry. */ - arpSessionId?: string; - timeoutMs?: number; - fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; -}): Promise<{ url: string; b64_json?: string; latest: unknown }> { - const fetchImpl = opts.fetchImpl || fetch; - const { spec } = resolveAdobeImageModel(opts.model); - const aspectRatio = normalizeAdobeAspectRatio(opts.aspectRatio ?? opts.size, "1:1"); - const outputResolution = normalizeAdobeOutputResolution(opts.quality, opts.size); - const payload = buildAdobeImagePayload({ - prompt: opts.prompt, - aspectRatio, - outputResolution, - modelSpec: spec, - quality: opts.quality, - seed: opts.seed, - sourceImageIds: opts.sourceImageIds, - negativePrompt: opts.negativePrompt, - }); - - const sessionCookie = String(opts.sessionCookie || "").trim(); - const cookieHeader = extractAdobeCookieHeader(sessionCookie); - // Prefer real browser sherlockToken / cookie rebuild (forter+arkose). Only the raw - // credential paste counts as "browser ARP" — never the pure synthetic fallback. - const hadBrowserArp = hasBrowserAdobeArpSession(cookieHeader || sessionCookie); - // Rotate ARP on each 408 retry — reusing a rejected Arkose/Forter session never recovers. - let arpSessionId = - (opts.arpSessionId && String(opts.arpSessionId).trim()) || - resolveAdobeArpSessionId(cookieHeader || sessionCookie); - let submitData: unknown = {}; - let submitHeaders: Headers | Record = new Headers(); - let lastSubmitError = ""; - let sawSystemUnderLoad = false; - let accessToken = opts.accessToken; - - for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) { - // Deterministic x-nonce from user_id+prompt; ARP may rotate after 408. - const submitResp = await fetchImpl(ADOBE_FIREFLY_IMAGE_SUBMIT_URL, { - method: "POST", - headers: buildAdobeSubmitHeaders(accessToken, { - arpSessionId, - prompt: opts.prompt, - cookie: cookieHeader || undefined, - }), - body: JSON.stringify(payload), - }); - - if (submitResp.status === 401 || submitResp.status === 403) { - const accessError = submitResp.headers.get("x-access-error") || ""; - if (accessError === "taste_exhausted") { - throw new AdobeFireflyError("Adobe Firefly quota exhausted for this account", 429, "quota_exhausted"); - } - throw new AdobeFireflyError( - "Adobe Firefly token invalid or expired. Paste a fresh IMS JWT (Authorization: Bearer on firefly-3p) " + - "plus the firefly.adobe.com Cookie once — the app will auto-refresh ARP after that.", - 401, - "auth" - ); - } - - if (!submitResp.ok) { - const text = await submitResp.text().catch(() => ""); - if (isAdobeTransientSubmitError(submitResp.status, text)) { - sawSystemUnderLoad = true; - } - lastSubmitError = `Adobe Firefly image submit failed (${submitResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`; - if (isAdobeTransientSubmitError(submitResp.status, text) && attempt < SUBMIT_MAX_ATTEMPTS) { - // Mint a fresh ARP for the next attempt (stale sherlockToken is the usual 408 cause). - try { - const { resolveAdobeArpSessionIdSmart, rotateAdobeFireflySessionOnError } = await import( - "./adobeFireflySession.ts" - ); - // Attempt 2: rebuild from cookies; attempt 3+: also try Playwright warm-up once. - // Prefer cookie rebuild over pure synthetic. Playwright warm is opt-in only — - // headless Forter tokens are rejected by colligo (still 408). - const tryBrowser = - attempt >= 3 && process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "1"; - if (cookieHeader) { - const rotated = await rotateAdobeFireflySessionOnError( - { - accessToken, - cookie: cookieHeader, - arpSessionId, - tokenExpiresAt: 0, - updatedAt: Date.now(), - fingerprint: "inline", - source: "rebuild", - }, - { tryBrowser, log: opts.log } - ); - accessToken = rotated.accessToken || accessToken; - arpSessionId = rotated.arpSessionId; - } else { - arpSessionId = resolveAdobeArpSessionIdSmart(sessionCookie, { rotate: true }); - } - } catch { - arpSessionId = buildAdobeArpSessionId(); - } - const delay = - Math.min(45_000, SUBMIT_BASE_DELAY_MS * Math.pow(2, attempt - 1)) + - Math.floor(Math.random() * 750); - opts.log?.info?.( - "ADOBE-FIREFLY", - `image submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms (ARP rotated)` - ); - await sleep(delay); - continue; - } - if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) { - throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("image", attempt, { hadBrowserArp }), - 408, - "system_under_load" - ); - } - throw new AdobeFireflyError( - lastSubmitError, - submitResp.status >= 400 && submitResp.status < 500 ? submitResp.status : 502 - ); - } - - submitData = await submitResp.json().catch(() => ({})); - submitHeaders = submitResp.headers; - break; - } - - let pollUrl = extractAdobeResultLink(submitHeaders, submitData); - if (!pollUrl) { - if (sawSystemUnderLoad) { - throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("image", SUBMIT_MAX_ATTEMPTS, { hadBrowserArp }), - 408, - "system_under_load" - ); - } - throw new AdobeFireflyError( - lastSubmitError || "Adobe Firefly image submit succeeded but no poll URL was returned", - 502 - ); - } - pollUrl = normalizeAdobePollUrl(pollUrl); - - const { mediaUrl, latest } = await pollAdobeJob({ - pollUrl, - accessToken: opts.accessToken, - kind: "image", - timeoutMs: opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_IMAGE_TIMEOUT_MS, - fetchImpl, - log: opts.log, - }); - - return { url: mediaUrl, latest }; -} - -export async function adobeFireflyGenerateVideo(opts: { - accessToken: string; - prompt: string; - model: string; - size?: unknown; - aspectRatio?: unknown; - duration?: unknown; - quality?: unknown; - resolution?: unknown; - seed?: number; - sourceImageIds?: string[]; - negativePrompt?: string; - generateAudio?: boolean; - sessionCookie?: string; - /** Shared ARP (sid+ark+ftr). Reuse with frame uploads. */ - arpSessionId?: string; - timeoutMs?: number; - fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; -}): Promise<{ url: string; b64_json?: string; format: string; latest: unknown }> { - const fetchImpl = opts.fetchImpl || fetch; - const { spec } = resolveAdobeVideoModel(opts.model); - const aspectRatio = normalizeAdobeAspectRatio(opts.aspectRatio ?? opts.size, "16:9"); - const duration = - typeof opts.duration === "number" - ? opts.duration - : typeof opts.duration === "string" && opts.duration.trim() - ? Number(opts.duration) - : spec.defaultDuration; - const resolution = - typeof opts.resolution === "string" && opts.resolution.trim() - ? opts.resolution - : typeof opts.quality === "string" && /p$/i.test(opts.quality) - ? opts.quality - : spec.defaultResolution; - - const payload = buildAdobeVideoPayload({ - prompt: opts.prompt, - aspectRatio, - duration: Number.isFinite(duration) ? Number(duration) : spec.defaultDuration, - modelSpec: spec, - resolution, - seed: opts.seed, - sourceImageIds: opts.sourceImageIds, - negativePrompt: opts.negativePrompt, - generateAudio: opts.generateAudio, - }); - - const sessionCookie = String(opts.sessionCookie || "").trim(); - const cookieHeader = extractAdobeCookieHeader(sessionCookie); - const hadBrowserArp = hasBrowserAdobeArpSession(cookieHeader || sessionCookie); - let arpSessionId = - (opts.arpSessionId && String(opts.arpSessionId).trim()) || - resolveAdobeArpSessionId(cookieHeader || sessionCookie); - let submitData: unknown = {}; - let submitHeaders: Headers | Record = new Headers(); - let lastSubmitError = ""; - let sawSystemUnderLoad = false; - let accessToken = opts.accessToken; - - for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) { - const submitResp = await fetchImpl(ADOBE_FIREFLY_VIDEO_SUBMIT_URL, { - method: "POST", - headers: buildAdobeSubmitHeaders(accessToken, { - arpSessionId, - prompt: opts.prompt, - cookie: cookieHeader || undefined, - }), - body: JSON.stringify(payload), - }); - - if (submitResp.status === 401 || submitResp.status === 403) { - const accessError = submitResp.headers.get("x-access-error") || ""; - if (accessError === "taste_exhausted") { - throw new AdobeFireflyError("Adobe Firefly quota exhausted for this account", 429, "quota_exhausted"); - } - throw new AdobeFireflyError( - "Adobe Firefly token invalid or expired. Paste a fresh IMS JWT (Authorization: Bearer on firefly-3p) " + - "plus the firefly.adobe.com Cookie once — the app will auto-refresh ARP after that.", - 401, - "auth" - ); - } - - if (!submitResp.ok) { - const text = await submitResp.text().catch(() => ""); - if (isAdobeTransientSubmitError(submitResp.status, text)) { - sawSystemUnderLoad = true; - } - lastSubmitError = `Adobe Firefly video submit failed (${submitResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`; - if (isAdobeTransientSubmitError(submitResp.status, text) && attempt < SUBMIT_MAX_ATTEMPTS) { - try { - const { resolveAdobeArpSessionIdSmart, rotateAdobeFireflySessionOnError } = await import( - "./adobeFireflySession.ts" - ); - const tryBrowser = - attempt >= 3 && process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "1"; - if (cookieHeader) { - const rotated = await rotateAdobeFireflySessionOnError( - { - accessToken, - cookie: cookieHeader, - arpSessionId, - tokenExpiresAt: 0, - updatedAt: Date.now(), - fingerprint: "inline", - source: "rebuild", - }, - { tryBrowser, log: opts.log } - ); - accessToken = rotated.accessToken || accessToken; - arpSessionId = rotated.arpSessionId; - } else { - arpSessionId = resolveAdobeArpSessionIdSmart(sessionCookie, { rotate: true }); - } - } catch { - arpSessionId = buildAdobeArpSessionId(); - } - const delay = - Math.min(45_000, SUBMIT_BASE_DELAY_MS * Math.pow(2, attempt - 1)) + - Math.floor(Math.random() * 750); - opts.log?.info?.( - "ADOBE-FIREFLY", - `video submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms (ARP rotated)` - ); - await sleep(delay); - continue; - } - if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) { - throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("video", attempt, { hadBrowserArp }), - 408, - "system_under_load" - ); - } - throw new AdobeFireflyError( - lastSubmitError, - submitResp.status >= 400 && submitResp.status < 500 ? submitResp.status : 502 - ); - } - - submitData = await submitResp.json().catch(() => ({})); - submitHeaders = submitResp.headers; - break; - } - - let pollUrl = extractAdobeResultLink(submitHeaders, submitData); - if (!pollUrl) { - if (sawSystemUnderLoad) { - throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("video", SUBMIT_MAX_ATTEMPTS, { hadBrowserArp }), - 408, - "system_under_load" - ); - } - throw new AdobeFireflyError( - lastSubmitError || "Adobe Firefly video submit succeeded but no poll URL was returned", - 502 - ); - } - pollUrl = normalizeAdobePollUrl(pollUrl); - - const { mediaUrl, latest } = await pollAdobeJob({ - pollUrl, - accessToken: opts.accessToken, - kind: "video", - timeoutMs: opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_VIDEO_TIMEOUT_MS, - fetchImpl, - log: opts.log, - }); - - return { url: mediaUrl, format: "mp4", latest }; -} +/** + * Adobe Firefly (unofficial) media client. + * + * Talks to the same Firefly 3P async APIs that firefly.adobe.com uses (live browser + * captures in repo `adobe/`): + * POST https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async + * POST https://firefly-3p.ff.adobe.io/v2/3p-videos/generate-async + * POST https://firefly-3p.ff.adobe.io/v2/models/discovery + * GET https://firefly.adobe.io/v1/credits/balance + * then polls BKS job result URLs rewritten from links.result. + * + * Auth is an Adobe IMS access token (Bearer, client_id = clio-playground-web). + * Callers may pass either: + * - a raw IMS access_token JWT (from Authorization: Bearer on Firefly), or + * - a browser Cookie header from firefly.adobe.com (exchanged via IMS check/v6/token + * with client_id clio-playground-web; Express projectx_webapp as fallback). + * + * x-api-key on generate/discovery MUST match the token's IMS client + * (`clio-playground-web`). Mismatch → HTTP 401 invalid token. + * + * Unofficial — tokens/cookies are short-lived; Adobe may change the wire contract. + */ + +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { resolvePublicCred } from "../utils/publicCreds.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; + +export const ADOBE_FIREFLY_IMAGE_SUBMIT_URL = + "https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async"; +export const ADOBE_FIREFLY_VIDEO_SUBMIT_URL = + "https://firefly-3p.ff.adobe.io/v2/3p-videos/generate-async"; +export const ADOBE_FIREFLY_IMAGE_UPLOAD_URL = + "https://firefly-3p.ff.adobe.io/v2/storage/image"; +export const ADOBE_FIREFLY_MODELS_DISCOVERY_URL = + "https://firefly-3p.ff.adobe.io/v2/models/discovery"; +export const ADOBE_FIREFLY_CREDITS_BALANCE_URL = + "https://firefly.adobe.io/v1/credits/balance"; +export const ADOBE_FIREFLY_IMS_REFRESH_URL = + "https://adobeid-na1.services.adobe.com/ims/check/v6/token?jslVersion=v2-v0.48.0-1-g1e322cb"; +/** Scope set observed on live firefly.adobe.com IMS access tokens. */ +export const ADOBE_FIREFLY_IMS_SCOPE = + "AdobeID,firefly_api,openid,pps.read,pps.write,additional_info.projectedProductContext," + + "additional_info.ownerOrg,uds_read,uds_write,ab.manage,read_organizations," + + "additional_info.roles,account_cluster.read,creative_production,tk_platform," + + "tk_platform_sync,profile"; + +const DEFAULT_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"; +const DEFAULT_SEC_CH_UA = + '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"'; +const DEFAULT_POLL_INTERVAL_MS = 3000; +const DEFAULT_IMAGE_TIMEOUT_MS = 180_000; +const DEFAULT_VIDEO_TIMEOUT_MS = 300_000; +const FIREFLY_ORIGIN = "https://firefly.adobe.com"; +const FIREFLY_REFERER = "https://firefly.adobe.com/"; + +export type AdobeFireflyImageModelId = + | "nano-banana-pro" + | "nano-banana" + | "nano-banana-2" + | "gpt-image" + | "gpt-image-2" + | "gpt-image-1.5" + | "flux-2" + | "flux-pro" + | "flux-ultra" + | "seedream-4" + | "seedream-5-lite" + | "runway-gen4-image"; + +export type AdobeFireflyVideoModelId = + | "sora-2" + | "sora-2-pro" + | "veo-3.1" + | "veo-3.1-fast" + | "veo-3.1-ref" + | "kling-3"; + +export interface AdobeFireflyImageModelSpec { + upstreamModelId: string; + upstreamModelVersion: string; + /** Payload builder family — nano uses Gemini-style size maps; gpt-image uses OpenAI detail levels. */ + family: "nano" | "gpt-image" | "generic"; +} + +export interface AdobeFireflyVideoModelSpec { + engine: "sora2" | "sora2-pro" | "veo31-standard" | "veo31-fast" | "kling3"; + upstreamModel: string; + modelId?: string; + modelVersion?: string; + referenceMode?: "frame" | "image"; + defaultDuration: number; + defaultResolution: string; +} + +/** + * Upstream modelId/modelVersion pairs from firefly-3p models/discovery + * (captured 2026-07 — see adobe/get_models.txt). Friendly catalog ids map here. + */ +export const ADOBE_FIREFLY_IMAGE_MODELS: Record = + { + // Gemini 3.0 (Nano Banana Pro) — discovery: gemini-flash / nano-banana-2 + "nano-banana-pro": { + upstreamModelId: "gemini-flash", + upstreamModelVersion: "nano-banana-2", + family: "nano", + }, + // Gemini 2.5 (Nano Banana) — discovery: gemini-flash / nano-banana + "nano-banana": { + upstreamModelId: "gemini-flash", + upstreamModelVersion: "nano-banana", + family: "nano", + }, + // Gemini 3.1 (Nano Banana 2) — discovery: gemini-flash / nano-banana-3 + "nano-banana-2": { + upstreamModelId: "gemini-flash", + upstreamModelVersion: "nano-banana-3", + family: "nano", + }, + // GPT Image 2 — discovery modelVersion "2" (get_models: modelDisplayName "GPT Image 2") + "gpt-image": { + upstreamModelId: "gpt-image", + upstreamModelVersion: "2", + family: "gpt-image", + }, + // Explicit catalog alias so pickers show "gpt-image-2" distinctly + "gpt-image-2": { + upstreamModelId: "gpt-image", + upstreamModelVersion: "2", + family: "gpt-image", + }, + "gpt-image-1.5": { + upstreamModelId: "gpt-image", + upstreamModelVersion: "1.5", + family: "gpt-image", + }, + "flux-2": { + upstreamModelId: "flux", + upstreamModelVersion: "2", + family: "generic", + }, + "flux-pro": { + upstreamModelId: "flux", + upstreamModelVersion: "fluxPro", + family: "generic", + }, + "flux-ultra": { + upstreamModelId: "flux", + upstreamModelVersion: "fluxUltra", + family: "generic", + }, + "seedream-4": { + upstreamModelId: "seedream", + upstreamModelVersion: "seedream_v4", + family: "generic", + }, + "seedream-5-lite": { + upstreamModelId: "seedream", + upstreamModelVersion: "seedream_v5_lite", + family: "generic", + }, + "runway-gen4-image": { + upstreamModelId: "runway-gen4-image", + upstreamModelVersion: "gen4_image", + family: "generic", + }, + }; + +export const ADOBE_FIREFLY_VIDEO_MODELS: Record = + { + "sora-2": { + engine: "sora2", + upstreamModel: "openai:firefly:colligo:sora2", + defaultDuration: 8, + defaultResolution: "720p", + }, + "sora-2-pro": { + engine: "sora2-pro", + upstreamModel: "openai:firefly:colligo:sora2-pro", + defaultDuration: 8, + defaultResolution: "720p", + }, + "veo-3.1": { + engine: "veo31-standard", + upstreamModel: "google:firefly:colligo:veo31", + modelId: "veo", + modelVersion: "3.1-generate", + defaultDuration: 6, + defaultResolution: "720p", + }, + "veo-3.1-fast": { + engine: "veo31-fast", + upstreamModel: "google:firefly:colligo:veo31-fast", + modelId: "veo", + modelVersion: "3.1-fast-generate", + defaultDuration: 6, + defaultResolution: "720p", + }, + "veo-3.1-ref": { + engine: "veo31-standard", + upstreamModel: "google:firefly:colligo:veo31", + modelId: "veo", + modelVersion: "3.1-generate", + referenceMode: "image", + defaultDuration: 6, + defaultResolution: "720p", + }, + "kling-3": { + engine: "kling3", + upstreamModel: "kling:firefly:colligo:kling3", + modelId: "kling", + modelVersion: "kling_v3_standard_i2v", + defaultDuration: 5, + defaultResolution: "1080p", + }, + }; + +const NANO_SIZE_MAP: Record> = { + "1K": { + "1:1": { width: 1024, height: 1024 }, + "16:9": { width: 1360, height: 768 }, + "9:16": { width: 768, height: 1360 }, + "4:3": { width: 1152, height: 864 }, + "3:4": { width: 864, height: 1152 }, + "1:8": { width: 384, height: 3072 }, + "1:4": { width: 512, height: 2048 }, + "4:1": { width: 2048, height: 512 }, + "8:1": { width: 3072, height: 384 }, + }, + "2K": { + "1:1": { width: 2048, height: 2048 }, + "16:9": { width: 2752, height: 1536 }, + "9:16": { width: 1536, height: 2752 }, + "4:3": { width: 2048, height: 1536 }, + "3:4": { width: 1536, height: 2048 }, + "1:8": { width: 768, height: 6144 }, + "1:4": { width: 1024, height: 4096 }, + "4:1": { width: 4096, height: 1024 }, + "8:1": { width: 6144, height: 768 }, + }, + "4K": { + "1:1": { width: 4096, height: 4096 }, + "16:9": { width: 5504, height: 3072 }, + "9:16": { width: 3072, height: 5504 }, + "4:3": { width: 4096, height: 3072 }, + "3:4": { width: 3072, height: 4096 }, + "1:8": { width: 1536, height: 12288 }, + "1:4": { width: 2048, height: 8192 }, + "4:1": { width: 8192, height: 2048 }, + "8:1": { width: 12288, height: 1536 }, + }, +}; + +const GPT_SIZE_MAP: Record> = { + "1K": { + "1:1": { width: 1024, height: 1024 }, + "5:4": { width: 1120, height: 896 }, + "9:16": { width: 720, height: 1280 }, + "21:9": { width: 1456, height: 624 }, + "16:9": { width: 1280, height: 720 }, + "4:3": { width: 1152, height: 864 }, + "3:2": { width: 1248, height: 832 }, + "4:5": { width: 896, height: 1120 }, + "3:4": { width: 864, height: 1152 }, + "2:3": { width: 832, height: 1248 }, + }, + "2K": { + "1:1": { width: 2048, height: 2048 }, + "5:4": { width: 2240, height: 1792 }, + "9:16": { width: 1440, height: 2560 }, + "21:9": { width: 3024, height: 1296 }, + "16:9": { width: 2560, height: 1440 }, + "4:3": { width: 2304, height: 1728 }, + "3:2": { width: 2496, height: 1664 }, + "4:5": { width: 1792, height: 2240 }, + "3:4": { width: 1728, height: 2304 }, + "2:3": { width: 1664, height: 2496 }, + }, + "4K": { + "1:1": { width: 2880, height: 2880 }, + "5:4": { width: 3200, height: 2560 }, + "9:16": { width: 2160, height: 3840 }, + "21:9": { width: 3696, height: 1584 }, + "16:9": { width: 3840, height: 2160 }, + "4:3": { width: 3264, height: 2448 }, + "3:2": { width: 3504, height: 2336 }, + "4:5": { width: 2560, height: 3200 }, + "3:4": { width: 2448, height: 3264 }, + "2:3": { width: 2336, height: 3504 }, + }, +}; + +const PIXEL_SIZE_TO_RATIO: Record = { + "1024x1024": "1:1", + "1536x1536": "1:1", + "2048x2048": "1:1", + "1024x1792": "9:16", + "1536x2752": "9:16", + "1792x1024": "16:9", + "2752x1536": "16:9", + "2048x1536": "4:3", + "1536x2048": "3:4", + "1280x720": "16:9", + "720x1280": "9:16", + "1920x1080": "16:9", + "1080x1920": "9:16", +}; + +export class AdobeFireflyError extends Error { + status: number; + code?: string; + + constructor(message: string, status = 502, code?: string) { + super(message); + this.name = "AdobeFireflyError"; + this.status = status; + this.code = code; + } +} + +/** Public x-api-key + primary IMS client_id for firefly.adobe.com (`clio-playground-web`). */ +export function adobeFireflyApiKey(): string { + return resolvePublicCred("adobe_firefly_api_key", "ADOBE_FIREFLY_API_KEY"); +} + +/** Express IMS client_id fallback for cookie exchange (`projectx_webapp`). */ +export function adobeFireflyExpressClientId(): string { + return resolvePublicCred("adobe_firefly_express_client_id", "ADOBE_FIREFLY_EXPRESS_CLIENT_ID"); +} + +/** Public x-api-key for GET firefly.adobe.io/v1/credits/balance (`SunbreakWebUI1`). */ +export function adobeFireflyBalanceApiKey(): string { + return resolvePublicCred("adobe_firefly_balance_api_key", "ADOBE_FIREFLY_BALANCE_API_KEY"); +} + +/** Decode IMS JWT payload (no signature verification — client-side claim read only). */ +export function decodeAdobeJwtPayload(token: string): Record | null { + try { + // Do not call extractAdobeCredentialToken here (would recurse via guest checks). + let raw = String(token || "").trim().replace(/^bearer\s+/i, "").trim(); + // If a blob was passed, take the first JWT-shaped segment. + const m = raw.match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/); + if (m) raw = m[0]; + const part = raw.split(".")[1]; + if (!part) return null; + const json = Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"); + const obj = JSON.parse(json); + return obj && typeof obj === "object" ? (obj as Record) : null; + } catch { + return null; + } +} + +/** AdobeID subject for x-account-id on balance / account_cluster calls. */ +export function extractAdobeAccountIdFromToken(token: string): string { + const payload = decodeAdobeJwtPayload(token); + if (!payload) return ""; + const candidates = [payload.user_id, payload.aa_id, payload.sub, payload.id]; + for (const c of candidates) { + if (typeof c === "string" && c.includes("@")) return c.trim(); + } + for (const c of candidates) { + if (typeof c === "string" && c.trim()) return c.trim(); + } + return ""; +} + +export function looksLikeAdobeJwt(value: string): boolean { + const raw = value.trim(); + if (!raw) return false; + // Avoid treating cookie blobs that happen to have two dots as JWT. + if (raw.includes(";") || (raw.includes("=") && !raw.startsWith("eyJ"))) return false; + // Allow a single space after optional Bearer prefix (stripped earlier). + if (/\s/.test(raw) && !/^bearer\s+/i.test(raw)) return false; + const token = raw.replace(/^bearer\s+/i, "").trim(); + const parts = token.split("."); + if (parts.length !== 3) return false; + // Adobe IMS access tokens are sizable; reject tiny accidental 3-segment strings. + if (token.length < 80) return false; + return parts.every((p) => p.length > 0 && /^[A-Za-z0-9_-]+$/.test(p)); +} + +/** + * True when IMS issued a guest token (no signed-in AdobeID). + * Live repro: firefly.adobe.com page cookies alone → account_type=guest → generate 401 / + * balance 403 ErrMismatchOauthToken. + */ +export function isAdobeGuestAccessToken(token: string): boolean { + const payload = decodeAdobeJwtPayload(token); + if (!payload) return false; + const userId = typeof payload.user_id === "string" ? payload.user_id : ""; + const aaId = typeof payload.aa_id === "string" ? payload.aa_id : ""; + const type = typeof payload.type === "string" ? payload.type.toLowerCase() : ""; + // Authenticated Firefly tokens always carry an @AdobeID (or similar) subject. + if (userId.includes("@AdobeID") || aaId.includes("@AdobeID")) return false; + if (userId.includes("@GuestID") || aaId.includes("@GuestID")) return true; + if (type === "guest" || type.includes("guest")) return true; + // Guest tokens from ims/check often omit type/user_id entirely. + if (!userId && !aaId) return true; + return false; +} + +export function isAdobeUserAccessToken(token: string): boolean { + return looksLikeAdobeJwt(token) && !isAdobeGuestAccessToken(token); +} + +/** + * Pull an IMS JWT out of free-form paste: raw JWT, Bearer …, access_token=…, + * IMS sessionStorage JSON (`tokenValue`), multi-line Network/HAR dumps. + * Prefer the longest user (non-guest) eyJ… JWT found. + */ +export function extractAdobeCredentialToken(raw: string): string { + const value = String(raw || "").trim(); + if (!value) return ""; + + if (/^bearer\s+/i.test(value)) { + const bare = value.replace(/^bearer\s+/i, "").trim().split(/\s+/)[0] || ""; + if (looksLikeAdobeJwt(bare)) return bare; + } + + // access_token=... in cookie-ish or form paste + const accessMatch = value.match(/(?:^|[;\s&])access_token=([^;\s&]+)/i); + if (accessMatch?.[1]) { + const t = decodeURIComponent(accessMatch[1].trim()); + if (looksLikeAdobeJwt(t)) return t; + } + + // IMS sessionStorage / localStorage JSON: "tokenValue":"eyJ..." + const tokenValueMatch = value.match(/"tokenValue"\s*:\s*"(eyJ[^"]+)"/i); + if (tokenValueMatch?.[1] && looksLikeAdobeJwt(tokenValueMatch[1])) { + return tokenValueMatch[1]; + } + + // Authorization: Bearer eyJ... + const authMatch = value.match(/Authorization\s*:\s*Bearer\s+([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i); + if (authMatch?.[1] && looksLikeAdobeJwt(authMatch[1])) return authMatch[1]; + + // Any eyJ… JWT in the blob (HAR / multi-line). Prefer user AdobeID tokens. + const jwtMatches = value.match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g); + if (jwtMatches && jwtMatches.length > 0) { + const sorted = [...jwtMatches].sort((a, b) => b.length - a.length); + const user = sorted.find((t) => looksLikeAdobeJwt(t) && isAdobeUserAccessToken(t)); + if (user) return user; + const best = sorted[0]; + if (looksLikeAdobeJwt(best)) return best; + } + + // Pure JWT + if (looksLikeAdobeJwt(value)) return value.replace(/^bearer\s+/i, "").trim(); + + // Cookie / other blob unchanged for IMS exchange + return value; +} + +/** + * True when the paste still looks like a Cookie header (not a bare JWT). + * Used to attach Cookie + sherlockToken → x-arp-session-id on generate. + */ +export function looksLikeAdobeCookieBlob(value: string): boolean { + const raw = String(value || "").trim(); + if (!raw || looksLikeAdobeJwt(raw)) return false; + if (raw.includes(";") && raw.includes("=")) return true; + if (/(?:^|[;\s])(?:aux_sid|ff_session|sherlockToken|forterToken|arkose)=/i.test(raw)) { + return true; + } + return false; +} + +/** + * Strip JWTs / Authorization lines from a mixed paste so only Cookie pairs remain. + * Undici Headers.append rejects multi-line Cookie values (throws Headers.append: "eyJ…"). + */ +export function extractAdobeCookieHeader(raw: string): string { + const value = String(raw || "").trim(); + if (!value) return ""; + if (looksLikeAdobeJwt(value)) return ""; + + // Drop pure JWT lines and Authorization: Bearer lines + const cleaned = value + .split(/[\r\n]+/) + .map((line) => line.trim()) + .filter((line) => { + if (!line) return false; + if (/^authorization\s*:/i.test(line)) return false; + if (/^bearer\s+/i.test(line)) return false; + if (looksLikeAdobeJwt(line)) return false; + // Drop standalone eyJ… segments + if (/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(line)) return false; + return true; + }) + .join("; "); + + // Also strip inline eyJ JWT tokens that may sit inside a cookie string + const noJwt = cleaned + .replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "") + .replace(/;\s*;/g, ";") + .replace(/^;\s*|\s*;$/g, "") + .trim(); + + if (!noJwt || !looksLikeAdobeCookieBlob(noJwt)) return ""; + // Final safety: Cookie header must be single-line + return noJwt.replace(/[\r\n]+/g, "; ").trim(); +} + +const GUEST_COOKIE_HELP = + "Firefly page cookies alone only mint a GUEST IMS token (no AdobeID) — generate returns 401 and Limits 403. " + + "Fix: open firefly.adobe.com signed-in → F12 → Network → click a request to firefly-3p.ff.adobe.io " + + "(generate-async or models/discovery) → Request Headers → Authorization → copy the token AFTER 'Bearer ' " + + "(starts with eyJ…). Paste that JWT as the credential. " + + "Cookie-only works only if you also export IMS session cookies from adobelogin.com / adobeid-na1 " + + "(Cookie-Editor → export all Adobe domains); firefly.adobe.com cookies by themselves are not enough."; + +export function normalizeAdobeAspectRatio(sizeOrRatio: unknown, fallback = "1:1"): string { + if (typeof sizeOrRatio !== "string" || !sizeOrRatio.trim()) return fallback; + let raw = sizeOrRatio.trim().replace(/_/g, ":"); + if (raw.toLowerCase() === "auto") return fallback; + + if (/^\d+:\d+$/.test(raw)) return raw; + + // Short ratio forms like 16x9 / 9x16 + const short = raw.match(/^(\d+)x(\d+)$/i); + if (short) { + const a = Number(short[1]); + const b = Number(short[2]); + if (a > 0 && b > 0 && a < 100 && b < 100) return `${a}:${b}`; + } + + const lower = raw.toLowerCase(); + if (PIXEL_SIZE_TO_RATIO[lower]) return PIXEL_SIZE_TO_RATIO[lower]; + + // Generic WxH pixel sizes → closest common ratio + const pixel = lower.match(/^(\d+)x(\d+)$/); + if (pixel) { + const w = Number(pixel[1]); + const h = Number(pixel[2]); + if (w > 0 && h > 0) { + if (Math.abs(w - h) / Math.max(w, h) < 0.08) return "1:1"; + if (w > h * 1.5) return "16:9"; + if (h > w * 1.5) return "9:16"; + if (w > h) return "4:3"; + return "3:4"; + } + } + + return fallback; +} + +export function normalizeAdobeOutputResolution(quality: unknown, size: unknown): "1K" | "2K" | "4K" { + const q = String(quality ?? "").trim().toLowerCase(); + if (q === "4k" || q === "ultra" || q === "high") return "4K"; + if (q === "2k" || q === "hd" || q === "standard" || q === "medium") return "2K"; + if (q === "1k" || q === "low") return "1K"; + + const s = String(size ?? "").toLowerCase(); + if (s.includes("4k") || /4096|5504|3840/.test(s)) return "4K"; + if (s.includes("1k") || /1024x1024|768x1360|1360x768/.test(s)) return "1K"; + return "2K"; +} + +export function resolveAdobeImageModel(model: string): { + id: AdobeFireflyImageModelId; + spec: AdobeFireflyImageModelSpec; +} { + const raw = String(model || "") + .trim() + .toLowerCase() + .replace(/^adobe-firefly\//, "") + .replace(/^firefly\//, ""); + + // Accept long catalog ids like firefly-nano-banana-pro-2k-16x9 + if (raw.includes("nano-banana2") || raw.includes("nano-banana-2") || raw.includes("nano-banana-3")) { + return { id: "nano-banana-2", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-2"] }; + } + if (raw.includes("nano-banana-pro")) { + return { id: "nano-banana-pro", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"] }; + } + if (raw.includes("nano-banana")) { + return { id: "nano-banana", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana"] }; + } + if (raw.includes("gpt-image-1.5") || raw.includes("gpt-image1.5")) { + return { id: "gpt-image-1.5", spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image-1.5"] }; + } + // Prefer explicit "2" / "gpt-image-2" before generic gpt-image + if ( + raw === "gpt-image-2" || + raw.includes("gpt-image-2") || + raw.includes("gptimage2") || + raw === "gpt-image" || + raw.includes("gpt-image") + ) { + // Bare gpt-image and gpt-image-2 both map to upstream version "2" (GPT Image 2). + if (raw.includes("1.5")) { + return { id: "gpt-image-1.5", spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image-1.5"] }; + } + const id = raw.includes("gpt-image-2") || raw.includes("gptimage2") ? "gpt-image-2" : "gpt-image"; + return { id: id as AdobeFireflyImageModelId, spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"] }; + } + if (raw.includes("flux-ultra") || raw.includes("fluxultra")) { + return { id: "flux-ultra", spec: ADOBE_FIREFLY_IMAGE_MODELS["flux-ultra"] }; + } + if (raw.includes("flux-pro") || raw.includes("fluxpro")) { + return { id: "flux-pro", spec: ADOBE_FIREFLY_IMAGE_MODELS["flux-pro"] }; + } + if (raw.includes("flux")) { + return { id: "flux-2", spec: ADOBE_FIREFLY_IMAGE_MODELS["flux-2"] }; + } + if (raw.includes("seedream-5") || raw.includes("seedream_v5")) { + return { id: "seedream-5-lite", spec: ADOBE_FIREFLY_IMAGE_MODELS["seedream-5-lite"] }; + } + if (raw.includes("seedream")) { + return { id: "seedream-4", spec: ADOBE_FIREFLY_IMAGE_MODELS["seedream-4"] }; + } + if (raw.includes("runway") && raw.includes("image")) { + return { id: "runway-gen4-image", spec: ADOBE_FIREFLY_IMAGE_MODELS["runway-gen4-image"] }; + } + + if (raw in ADOBE_FIREFLY_IMAGE_MODELS) { + const id = raw as AdobeFireflyImageModelId; + return { id, spec: ADOBE_FIREFLY_IMAGE_MODELS[id] }; + } + + // Default to Nano Banana Pro (most common Firefly image path). + return { id: "nano-banana-pro", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"] }; +} + +export function resolveAdobeVideoModel(model: string): { + id: AdobeFireflyVideoModelId; + spec: AdobeFireflyVideoModelSpec; +} { + const raw = String(model || "") + .trim() + .toLowerCase() + .replace(/^adobe-firefly\//, "") + .replace(/^firefly\//, ""); + + if (raw.includes("sora2-pro") || raw.includes("sora-2-pro") || raw.includes("sora2_pro")) { + return { id: "sora-2-pro", spec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2-pro"] }; + } + if (raw.includes("sora2") || raw.includes("sora-2") || raw.includes("sora")) { + return { id: "sora-2", spec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2"] }; + } + if (raw.includes("veo31-ref") || raw.includes("veo-3.1-ref") || raw.includes("veo31_ref")) { + return { id: "veo-3.1-ref", spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1-ref"] }; + } + if (raw.includes("veo31-fast") || raw.includes("veo-3.1-fast") || raw.includes("veo31_fast")) { + return { id: "veo-3.1-fast", spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1-fast"] }; + } + if (raw.includes("veo31") || raw.includes("veo-3.1") || raw.includes("veo")) { + return { id: "veo-3.1", spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1"] }; + } + if (raw.includes("kling")) { + return { id: "kling-3", spec: ADOBE_FIREFLY_VIDEO_MODELS["kling-3"] }; + } + + if (raw in ADOBE_FIREFLY_VIDEO_MODELS) { + const id = raw as AdobeFireflyVideoModelId; + return { id, spec: ADOBE_FIREFLY_VIDEO_MODELS[id] }; + } + + return { id: "sora-2", spec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2"] }; +} + +function gptDetailLevel(quality: unknown): number { + // Live firefly.adobe.com default for gpt-image is detailLevel 3 (medium). + const q = String(quality ?? "medium").trim().toLowerCase(); + if (q === "high" || q === "4k" || q === "ultra") return 5; + if (q === "low" || q === "1k") return 1; + if (q === "medium" || q === "2k" || q === "standard" || q === "hd" || q === "auto") return 3; + return 3; +} + +export function buildAdobeImagePayload(opts: { + prompt: string; + aspectRatio: string; + outputResolution: "1K" | "2K" | "4K"; + modelSpec: AdobeFireflyImageModelSpec; + quality?: unknown; + seed?: number; + sourceImageIds?: string[]; + negativePrompt?: string; +}): Record { + const ratio = opts.aspectRatio === "auto" ? "1:1" : opts.aspectRatio || "1:1"; + const seeds = [typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999)]; + const negative = String(opts.negativePrompt || "").trim(); + const genSettings: Record = {}; + if (negative) { + genSettings.avoidKeywords = negative + .replace(/;/g, ",") + .split(",") + .map((w) => w.trim()) + .filter(Boolean); + } + + if (opts.modelSpec.family === "gpt-image") { + // Live firefly.adobe.com body (adobe/image_generate.txt) — no top-level size / + // outputResolution; modelSpecificPayload.size is "auto". + const payload: Record = { + n: 1, + seeds, + output: { storeInputs: true }, + prompt: opts.prompt, + referenceBlobs: [] as Array>, + modelSpecificPayload: { size: "auto" }, + modelId: opts.modelSpec.upstreamModelId, + modelVersion: opts.modelSpec.upstreamModelVersion, + generationMetadata: { module: "text2image", submodule: "ff-image-generate" }, + generationSettings: { + detailLevel: gptDetailLevel(opts.quality), + ...genSettings, + }, + }; + if (opts.sourceImageIds?.length) { + // gpt-image subject references (mask path uses separate mask blob when present). + payload.generationMetadata = { module: "image2image", submodule: "ff-image-generate" }; + payload.referenceBlobs = opts.sourceImageIds.map((id) => ({ + id: String(id), + usage: "subject", + })); + payload.modelSpecificPayload = {}; + } + return payload; + } + + // nano (Gemini Flash) + generic (Flux / Seedream / Runway image): same 3P image shape. + // Live capture (web_providers/adobe_atach_images.txt): referenceBlobs with usage "general" + // keep module "text2image" (not image2image) for nano multi-ref composition. + const sizeMap = NANO_SIZE_MAP[opts.outputResolution] || NANO_SIZE_MAP["2K"]; + const pixel = sizeMap[ratio] || sizeMap["1:1"]; + const payload: Record = { + modelId: opts.modelSpec.upstreamModelId, + modelVersion: opts.modelSpec.upstreamModelVersion, + n: 1, + prompt: opts.prompt, + size: pixel, + seeds, + groundSearch: false, + skipCai: false, + output: { storeInputs: true }, + generationMetadata: { module: "text2image", submodule: "ff-image-generate" }, + modelSpecificPayload: { + parameters: { addWatermark: false }, + aspectRatio: ratio, + }, + referenceBlobs: [] as Array>, + }; + if (Object.keys(genSettings).length) payload.generationSettings = genSettings; + + if (opts.sourceImageIds?.length) { + payload.referenceBlobs = opts.sourceImageIds.map((id) => ({ + id: String(id), + usage: "general", + })); + // Flux / Seedream / Runway image historically used image2image; nano keeps text2image. + if (opts.modelSpec.family === "generic") { + payload.generationMetadata = { module: "image2image", submodule: "ff-image-generate" }; + } + } + return payload; +} + +function videoSize(aspectRatio: string, resolution: string): { width: number; height: number } { + const res = String(resolution || "720p").toLowerCase(); + const short = res.includes("1080") ? 1080 : res.includes("480") ? 480 : 720; + const ratio = aspectRatio === "9:16" ? "9:16" : aspectRatio === "1:1" ? "1:1" : "16:9"; + if (ratio === "1:1") return { width: short, height: short }; + if (ratio === "9:16") return { width: Math.round((short * 9) / 16), height: short }; + return { width: Math.round((short * 16) / 9), height: short }; +} + +export function buildAdobeVideoPayload(opts: { + prompt: string; + aspectRatio: string; + duration: number; + modelSpec: AdobeFireflyVideoModelSpec; + resolution?: string; + seed?: number; + sourceImageIds?: string[]; + negativePrompt?: string; + generateAudio?: boolean; +}): Record { + const seedVal = typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999); + const aspect = opts.aspectRatio === "auto" ? "16:9" : opts.aspectRatio || "16:9"; + const duration = Math.max(1, Math.min(30, Math.floor(opts.duration || opts.modelSpec.defaultDuration))); + const resolution = opts.resolution || opts.modelSpec.defaultResolution; + const vidSize = videoSize(aspect, resolution); + const engine = opts.modelSpec.engine; + const sourceImageIds = opts.sourceImageIds || []; + const negative = String(opts.negativePrompt || ""); + + if (engine === "veo31-standard" || engine === "veo31-fast") { + const payload: Record = { + n: 1, + seeds: [seedVal], + modelId: "veo", + modelVersion: + opts.modelSpec.modelVersion || + (engine === "veo31-fast" ? "3.1-fast-generate" : "3.1-generate"), + output: { storeInputs: true }, + prompt: opts.prompt, + size: vidSize, + generateAudio: opts.generateAudio !== false, + referenceBlobs: [] as Array>, + generationMetadata: { module: "text2video" }, + modelSpecificPayload: { + parameters: { + durationSeconds: duration, + aspectRatio: aspect, + addWaterMark: false, + }, + }, + }; + if (sourceImageIds.length) { + const refs = payload.referenceBlobs as Array>; + if (opts.modelSpec.referenceMode === "image") { + for (const imageId of sourceImageIds.slice(0, 3)) { + refs.push({ id: String(imageId), usage: "asset" }); + } + } else { + sourceImageIds.slice(0, 2).forEach((imageId, idx) => { + refs.push({ id: String(imageId), usage: "general", order: idx + 1 }); + }); + } + payload.generationMetadata = { module: "image2video" }; + } + if (negative) payload.negativePrompt = negative; + return payload; + } + + if (engine === "kling3") { + const payload: Record = { + n: 1, + seeds: [seedVal], + modelId: "kling", + modelVersion: "kling_v3_standard_i2v", + output: { storeInputs: true }, + prompt: opts.prompt, + size: vidSize, + generationMetadata: { + module: sourceImageIds.length ? "image2video" : "text2video", + }, + duration, + generationSettings: { aspectRatio: aspect }, + referenceBlobs: [] as Array>, + }; + if (sourceImageIds.length) { + const refs = payload.referenceBlobs as Array>; + sourceImageIds.slice(0, 2).forEach((imageId, idx) => { + refs.push({ id: String(imageId), usage: "frame", order: idx + 1 }); + }); + } + if (negative) payload.negativePrompt = negative; + return payload; + } + + // Sora 2 / Sora 2 Pro + const promptJson = JSON.stringify({ + prompt: opts.prompt, + duration, + ...(negative ? { negative_prompt: negative } : {}), + }); + const payload: Record = { + n: 1, + seeds: [seedVal], + modelId: "sora", + modelVersion: engine === "sora2-pro" ? "sora-2-pro" : "sora-2", + size: vidSize, + duration, + fps: 24, + prompt: promptJson, + generationMetadata: { module: sourceImageIds.length ? "image2video" : "text2video" }, + model: opts.modelSpec.upstreamModel, + generateLoop: false, + transparentBackground: false, + seed: String(seedVal), + locale: "en-US", + camera: { angle: "none", shotSize: "none", motion: null, promptStyle: null }, + negativePrompt: negative, + jobMode: "standard", + debugGenerationEndpoint: "", + referenceBlobs: [] as Array>, + referenceFrames: [] as Array | null>, + referenceVideo: null, + cameraMotionReferenceVideo: null, + characterReference: null, + editReferenceVideo: null, + output: { storeInputs: true }, + }; + if (sourceImageIds.length) { + const firstId = String(sourceImageIds[0]); + payload.referenceBlobs = [{ id: firstId, usage: "general", promptReference: 1 }]; + const frames: Array | null> = [{ localBlobRef: firstId }, null]; + if (sourceImageIds.length > 1) { + const lastId = String(sourceImageIds[1]); + (payload.referenceBlobs as Array>).push({ + id: lastId, + usage: "general", + promptReference: 2, + }); + frames[1] = { localBlobRef: lastId }; + } + payload.referenceFrames = frames; + } + return payload; +} + +function browserHeaders(): Record { + return { + "user-agent": DEFAULT_USER_AGENT, + origin: FIREFLY_ORIGIN, + referer: FIREFLY_REFERER, + "accept-language": "en-US,en;q=0.9", + "sec-ch-ua": DEFAULT_SEC_CH_UA, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }; +} + +/** Random 64-char hex fallback when token/prompt are missing for deterministic nonce. */ +export function generateAdobeNonce(): string { + const bytes = new Uint8Array(32); + if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { + crypto.getRandomValues(bytes); + } else { + for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); + } + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** + * Deterministic x-nonce used by working open-source Firefly clients + * (adobe2api / GPT2Image-Pro / image2api): + * sha256(`${user_id}-${prompt.slice(0, 256)}`) + * + * Random nonces (browser-looking) still get colligo 408 on many accounts when + * the request is not from the SPA. Deterministic nonce is what unblocks generate. + */ +export function buildAdobeSubmitNonce(accessToken: string, prompt: string): string { + const userId = extractAdobeAccountIdFromToken(accessToken); + const promptPrefix = String(prompt || "").slice(0, 256); + if (!userId || !promptPrefix) return ""; + return createHash("sha256").update(`${userId}-${promptPrefix}`, "utf8").digest("hex"); +} + +/** + * Live firefly.adobe.com Arkose public key (web_providers/adobe_atach_images.txt, 2026-07). + * Browser x-arp-session-id is base64(JSON({sid, ark, ftr})) — synthetic sessions without a + * real Arkose blob often get colligo HTTP 408 "system under load". Prefer pasted sherlockToken. + */ +export const ADOBE_FIREFLY_ARKOSE_PUBLIC_KEY = "BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C"; +/** Live ftr magic (replaces older adobe2api `dUAL43-mnts-ants-d4_31ck__tt`). */ +export const ADOBE_FIREFLY_FTR_MAGIC = "__UDF43-m4_31ck"; + +/** + * True when a string looks like a Firefly ARP session (base64 JSON with sid). + */ +export function isValidAdobeArpSessionId(value: string): boolean { + const t = String(value || "").trim(); + if (t.length < 4) return false; + // Never treat Cookie name=value pairs (e.g. aux_sid=…, forter=…) as ARP. + // Live ARP is base64(JSON) or a bare opaque token — not "key=value". + if (/^[A-Za-z_][A-Za-z0-9_.%-]*=/.test(t) && !t.startsWith("eyJ")) return false; + try { + const padded = t + "=".repeat((4 - (t.length % 4)) % 4); + const json = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString( + "utf8" + ); + // Reject binary garbage that "decodes" but isn't JSON (corrupted sherlock paste). + if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(json)) return false; + const obj = JSON.parse(json) as { sid?: unknown; ftr?: unknown; ark?: unknown }; + return typeof obj.sid === "string" && obj.sid.length > 0; + } catch { + // Opaque short sherlockToken values (tests / non-JSON) when non-empty. + // No mid-string "=" (cookie pair leftovers); padding "=" at end is OK. + if (/=.+/.test(t.replace(/=+$/, ""))) return false; + return !looksLikeAdobeJwt(t) && /^[A-Za-z0-9+/_=-]+$/.test(t); + } +} + +/** + * Synthesize x-arp-session-id when no browser sherlockToken is available. + * Shape matches live successful generate (adobe/image_generate.txt): + * base64(JSON({sid, ark, bfp, ftr, fpjs})) + * ALWAYS send this header on generate-async / storage upload. + * Prefer real sherlockToken / cookie rebuild (forter+arkose+sid) when available. + */ +export function buildAdobeArpSessionId(region = "eu-west-1"): string { + const nowMs = Date.now(); + const sid = randomUUID(); + const randHex = randomBytes(16).toString("hex"); + // Live ftr: {32hex}_{ms}__UDF43-m4_31ck_{b64}=-N-v2_tt + const mid = randomBytes(12).toString("base64url"); + const n = 1000 + Math.floor(Math.random() * 9000); + const ftr = `${randHex}_${nowMs}${ADOBE_FIREFLY_FTR_MAGIC}_${mid}=-${n}-v2_tt`; + // Arkose session-shaped string (public pk from firefly SPA). Without a real + // Arkose solve this may still 408; real sherlockToken is the stable path. + const arkSession = `${randomBytes(8).toString("hex")}.${Math.random().toFixed(10).slice(2)}`; + const ark = + `${arkSession}|r=${region}|meta=3|metabgclr=transparent|metaiconclr=%23757575|` + + `guitextcolor=%23000000|pk=${ADOBE_FIREFLY_ARKOSE_PUBLIC_KEY}|at=40|sup=1|rid=13|ag=101|` + + `cdn_url=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc|` + + `surl=https%3A%2F%2Farks-client.adobe.com|` + + `smurl=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc%2Fassets%2Fstyle-manager`; + // Successful browser ARP also carries Browser Fingerprint + FingerprintJS payload. + const bfp = randomUUID(); + const fpjs = JSON.stringify({ + requestId: `${nowMs}.${randomBytes(3).toString("base64url")}`, + visitorId: randomBytes(12).toString("base64url"), + }); + const raw = JSON.stringify({ sid, ark, bfp, ftr, fpjs }); + return Buffer.from(raw, "utf-8").toString("base64"); +} + +/** + * Pull sherlockToken / x-arp-session-id from Cookie header, HAR paste, or multi-line credential. + * Browser generate sends Cookie.sherlockToken (or the request header) as x-arp-session-id. + * Live value is base64({sid, ark, ftr}) — includes Arkose session data. + * + * Also handles PasswordBox mangling (JWT + ARP joined by a single space) and full fetch() + * copy/paste from DevTools (web_providers/adobe_atach_images.txt). + */ +export function extractAdobeArpSessionId(cookieOrBlob: string): string { + const raw = String(cookieOrBlob || ""); + if (!raw.trim()) return ""; + + const candidates: string[] = []; + const push = (v: string | undefined | null) => { + if (!v) return; + let t = v.trim().replace(/^["']|["']$/g, "").trim(); + try { + // Cookie values are often URI-encoded + if (/%[0-9A-Fa-f]{2}/.test(t)) t = decodeURIComponent(t); + } catch { + /* keep raw */ + } + if (t) candidates.push(t); + }; + + // Cookie: sherlockToken=... + const m = raw.match(/(?:^|[;\s\n\r])sherlockToken=([^;\s\n\r]+)/i); + if (m?.[1]) push(m[1]); + + // Cookie or form: x-arp-session-id=... + const m2 = raw.match(/(?:^|[;\s\n\r])x-arp-session-id=([^;\s\n\r]+)/i); + if (m2?.[1]) push(m2[1]); + + // HAR / Network / fetch() headers: "x-arp-session-id": "eyJ..." or x-arp-session-id: eyJ... + const m3 = raw.match(/["']?x-arp-session-id["']?\s*[:=]\s*["']?([A-Za-z0-9+/=_-]{40,})["']?/i); + if (m3?.[1]) push(m3[1]); + + // HAR: "sherlockToken": "eyJ..." + const m4 = raw.match(/["']?sherlockToken["']?\s*[:=]\s*["']?([A-Za-z0-9+/=_-]{40,})["']?/i); + if (m4?.[1]) push(m4[1]); + + // Bare base64 ARP blob on its own line (line 2 of two-line paste) + for (const line of raw.split(/[\r\n]+/)) { + const t = line.trim().replace(/^["']|["']$/g, ""); + // Skip pure JWT lines + if (looksLikeAdobeJwt(t)) continue; + if (t.length >= 40 && isValidAdobeArpSessionId(t)) push(t); + } + + // JWT + ARP joined by whitespace (single-line PasswordBox paste collapses \n → space) + // Split on whitespace only — NOT on "=" — so we never treat "aux_sid=…" as a token. + const withoutJwt = raw.replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, " "); + for (const token of withoutJwt.split(/[\s,;"']+/)) { + let t = token.trim(); + // If this chunk is name=value from a Cookie header, only keep the value when + // the name is sherlockToken / x-arp-session-id; skip aux_sid, forter, etc. + const eq = t.indexOf("="); + if (eq > 0 && eq < 40 && /^[A-Za-z0-9_.%-]+$/.test(t.slice(0, eq))) { + const name = t.slice(0, eq).toLowerCase(); + if (name === "sherlocktoken" || name === "x-arp-session-id") { + t = t.slice(eq + 1).trim(); + } else { + continue; + } + } + if (t.length >= 40 && isValidAdobeArpSessionId(t)) push(t); + } + + // Prefer ARP that decodes to JSON with sid+ark (real browser session over opaque short tokens) + const ranked = candidates + .map((c) => c.replace(/^["']|["']$/g, "").trim()) + .filter((v) => isValidAdobeArpSessionId(v)); + ranked.sort((a, b) => scoreAdobeArpCandidate(b) - scoreAdobeArpCandidate(a)); + return ranked[0] || ""; +} + +/** Higher = more like a live firefly-3p x-arp-session-id (sid+ark+ftr[+bfp+fpjs] base64). */ +function scoreAdobeArpCandidate(value: string): number { + let score = value.length; + try { + const padded = value + "=".repeat((4 - (value.length % 4)) % 4); + const json = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"); + const obj = JSON.parse(json) as { + sid?: unknown; + ark?: unknown; + ftr?: unknown; + bfp?: unknown; + fpjs?: unknown; + }; + if (typeof obj.sid === "string" && obj.sid) score += 1000; + if (typeof obj.ark === "string" && obj.ark.length > 20) score += 500; + if (typeof obj.ftr === "string" && obj.ftr.includes(ADOBE_FIREFLY_FTR_MAGIC)) score += 200; + if (typeof obj.ark === "string" && obj.ark.includes(ADOBE_FIREFLY_ARKOSE_PUBLIC_KEY)) score += 100; + // Live successful generates (adobe/image_generate.txt) include browser fingerprint fields. + if (typeof obj.bfp === "string" && obj.bfp.length >= 8) score += 150; + if (typeof obj.fpjs === "string" && obj.fpjs.length > 10) score += 150; + } catch { + /* opaque sherlockToken */ + } + return score; +} + +/** + * True when the credential blob already contains a browser ARP / sherlockToken + * OR enough cookie pieces to rebuild one (ff_session_guid + arkose + forterToken). + * Synthetic-only ARP is a fallback — real cookie pieces are required for stable generate. + */ +export function hasBrowserAdobeArpSession(sessionCookieOrBlob?: string): boolean { + const blob = String(sessionCookieOrBlob || ""); + if (extractAdobeArpSessionId(blob)) return true; + // Rebuild path counts as browser ARP (same pieces the SPA uses for sherlockToken). + const sid = blob.match(/(?:^|[;\s])ff_session_guid=([^;\s]+)/i)?.[1]; + const ark = blob.match(/(?:^|[;\s])arkose=([^;\s]+)/i)?.[1]; + const ftr = + blob.match(/(?:^|[;\s])forterToken=([^;\s]+)/i)?.[1] || + blob.match(/(?:^|[;\s])forter=([^;\s]+)/i)?.[1]; + return Boolean(sid && ark && ftr && !/^[a-f0-9]{32},\d+$/i.test(ftr)); +} + +/** + * Resolve ARP for a Firefly request. + * Prefer cookie rebuild (ff_session_guid + arkose + forterToken [+bfp/fpjs]) over a + * frozen sherlockToken paste — Forter advances while the pasted ARP goes stale. + * Fall back to sherlockToken / x-arp-session-id extract, then synthetic rich ARP. + * Mint once per generate/upload chain and reuse (browser uses the same ARP for upload+submit); + * on 408 the submit loop rotates ARP separately. + */ +export function resolveAdobeArpSessionId(sessionCookieOrBlob?: string): string { + const blob = String(sessionCookieOrBlob || ""); + // Lazy require of rebuild helper to avoid circular import at module load. + // Inline minimal rebuild here (sid+ark+ftr) so resolve stays self-contained. + const getCookie = (name: string): string => { + const m = blob.match( + new RegExp(`(?:^|[;\\s\\n\\r])${name}=([^;\\s\\n\\r]+)`, "i") + ); + if (!m?.[1]) return ""; + let v = m[1].trim(); + try { + if (/%[0-9A-Fa-f]{2}/.test(v)) v = decodeURIComponent(v); + } catch { + /* keep */ + } + return v; + }; + const sid = getCookie("ff_session_guid"); + const ark = getCookie("arkose"); + let ftr = getCookie("forterToken") || getCookie("forter"); + try { + if (/%[0-9A-Fa-f]{2}/.test(ftr)) ftr = decodeURIComponent(ftr); + } catch { + /* keep */ + } + if (ftr.endsWith("v2") && !ftr.endsWith("v2_tt")) ftr = `${ftr}_tt`; + // Skip localStorage-style "id,timestamp" forter values + if (/^[a-f0-9]{32},\d+$/i.test(ftr)) ftr = ""; + if (sid && ark && ftr) { + const bfp = getCookie("bfp"); + let fpjs = getCookie("fpjs"); + try { + if (fpjs && /%[0-9A-Fa-f]{2}/.test(fpjs)) fpjs = decodeURIComponent(fpjs); + } catch { + /* keep */ + } + const obj: Record = { sid, ark, ftr }; + if (bfp) obj.bfp = bfp; + if (fpjs) obj.fpjs = fpjs; + return Buffer.from(JSON.stringify(obj), "utf-8").toString("base64"); + } + const extracted = extractAdobeArpSessionId(blob); + if (extracted) return extracted; + return buildAdobeArpSessionId(); +} + +export function buildAdobeSubmitHeaders( + accessToken: string, + extras?: { + arpSessionId?: string; + nonce?: string; + cookie?: string; + /** Required for deterministic x-nonce (sha256 user_id+prompt). */ + prompt?: string; + } +): Record { + // Live capture (web_providers/adobe_atach_images.txt) + working clients: + // Authorization + x-api-key + x-nonce + ALWAYS x-arp-session-id (sid+ark+ftr). + // Do NOT attach firefly.adobe.com page Cookie to firefly-3p (wrong origin). + // Prefer real sherlockToken from cookie blob; synthetic ARP is fallback only. + const cookieBlob = String(extras?.cookie || "").trim(); + const deterministic = + extras?.nonce || + (extras?.prompt ? buildAdobeSubmitNonce(accessToken, extras.prompt) : "") || + generateAdobeNonce(); + // Explicit arpSessionId wins (caller may pass synthetic short test ids or real browser ARP). + const explicitArp = extras?.arpSessionId ? String(extras.arpSessionId).trim() : ""; + const arp = + explicitArp || + extractAdobeArpSessionId(cookieBlob) || + buildAdobeArpSessionId(); + const headers: Record = { + ...browserHeaders(), + Authorization: `Bearer ${accessToken}`, + // Must be clio-playground-web — same client_id that minted the IMS token. + "x-api-key": adobeFireflyApiKey(), + "content-type": "application/json", + accept: "*/*", + "cache-control": "no-cache", + pragma: "no-cache", + priority: "u=1, i", + "x-nonce": deterministic, + "x-arp-session-id": arp, + }; + return headers; +} + +/** Max reference image size for Firefly storage upload (20 MiB). */ +export const ADOBE_FIREFLY_MAX_UPLOAD_BYTES = 20 * 1024 * 1024; + +/** + * Headers for POST /v2/storage/image (raw image body). + * Live capture (web_providers/adobe_atach_images.txt): Bearer + x-api-key + x-arp + x-nonce + * + content-type image/png|jpeg (not application/json). + */ +export function buildAdobeUploadHeaders( + accessToken: string, + contentType: string, + extras?: { + arpSessionId?: string; + nonce?: string; + cookie?: string; + prompt?: string; + } +): Record { + const base = buildAdobeSubmitHeaders(accessToken, { + arpSessionId: extras?.arpSessionId, + nonce: extras?.nonce, + cookie: extras?.cookie, + prompt: extras?.prompt || "upload", + }); + const ct = String(contentType || "image/png").trim().toLowerCase() || "image/png"; + return { + ...base, + "content-type": ct.startsWith("image/") ? ct : "image/png", + }; +} + +/** + * Collect reference image sources from an OpenAI-style / Media-page image|video body. + * Supports: image_url, image, images[], image_urls[], input_image(s), reference_images, + * provider_options.*, and prompt_image fields used by the WinUI Media page. + */ +export function extractAdobeSourceImageSources(body: unknown, max = 4): string[] { + if (!body || typeof body !== "object") return []; + const b = body as Record; + const po = + b.provider_options && typeof b.provider_options === "object" && !Array.isArray(b.provider_options) + ? (b.provider_options as Record) + : {}; + + const out: string[] = []; + const seen = new Set(); + const push = (v: unknown) => { + if (out.length >= max) return; + if (typeof v === "string") { + const t = v.trim(); + if (!t || seen.has(t)) return; + // Skip empty / clearly non-image + if (t === "null" || t === "undefined") return; + seen.add(t); + out.push(t); + return; + } + if (Array.isArray(v)) { + for (const item of v) { + if (out.length >= max) break; + push(item); + } + return; + } + if (v && typeof v === "object") { + const o = v as Record; + if (typeof o.url === "string") push(o.url); + else if (typeof o.image_url === "string") push(o.image_url); + else if (o.image_url && typeof o.image_url === "object") { + const inner = (o.image_url as Record).url; + if (typeof inner === "string") push(inner); + } else if (typeof o.b64_json === "string") { + push(`data:image/png;base64,${o.b64_json}`); + } else if (typeof o.base64 === "string") { + push(`data:image/png;base64,${o.base64}`); + } + } + }; + + // Order matches MediaViewModel / OpenAI edit aliases (primary single fields first). + const keys = [ + "image_url", + "imageUrl", + "input_image", + "source_image", + "promptImage", + "prompt_image", + "image", + "images", + "image_urls", + "imageUrls", + "input_images", + "reference_images", + "referenceImages", + "reference_image", + ]; + for (const k of keys) { + push(b[k]); + push(po[k]); + } + + // OpenAI chat-style content parts (rare on /v1/images but harmless). + if (Array.isArray(b.messages)) { + for (const msg of b.messages) { + if (!msg || typeof msg !== "object") continue; + const content = (msg as Record).content; + if (!Array.isArray(content)) continue; + for (const part of content) { + if (!part || typeof part !== "object") continue; + const p = part as Record; + if (p.type === "image_url" || p.type === "image") { + push(p.image_url ?? p.image ?? p.url); + } + } + } + } + + return out.slice(0, max); +} + +export function parseAdobeImageSourceBytes(source: string): { + buffer: Buffer; + contentType: string; +} { + const trimmed = String(source || "").trim(); + if (!trimmed) { + throw new AdobeFireflyError("Empty image reference", 400, "bad_image"); + } + + const dataUri = /^data:([^;,]+)?(?:;charset=[^;,]+)?(;base64)?,([\s\S]+)$/i.exec(trimmed); + if (dataUri) { + const mime = (dataUri[1] || "image/png").trim().toLowerCase() || "image/png"; + const isB64 = Boolean(dataUri[2]); + const payload = dataUri[3] || ""; + if (!isB64) { + throw new AdobeFireflyError( + "Image data URL must be base64-encoded (data:image/...;base64,...)", + 400, + "bad_image" + ); + } + const buffer = Buffer.from(payload.replace(/\s/g, ""), "base64"); + if (!buffer.length) { + throw new AdobeFireflyError("Image data URL decoded to empty bytes", 400, "bad_image"); + } + if (buffer.length > ADOBE_FIREFLY_MAX_UPLOAD_BYTES) { + throw new AdobeFireflyError( + `Image reference too large (${buffer.length} bytes; max ${ADOBE_FIREFLY_MAX_UPLOAD_BYTES})`, + 400, + "bad_image" + ); + } + return { buffer, contentType: mime.startsWith("image/") ? mime : "image/png" }; + } + + // Raw base64 without data: prefix + if (!/^https?:\/\//i.test(trimmed) && /^[A-Za-z0-9+/=\s]+$/.test(trimmed) && trimmed.length > 64) { + const buffer = Buffer.from(trimmed.replace(/\s/g, ""), "base64"); + if (buffer.length > 0 && buffer.length <= ADOBE_FIREFLY_MAX_UPLOAD_BYTES) { + return { buffer, contentType: "image/png" }; + } + } + + throw new AdobeFireflyError( + "Unsupported image reference (need data:image/...;base64,... or raw base64). " + + "HTTP(S) URLs are resolved by the caller before upload.", + 400, + "bad_image" + ); +} + +/** + * Parse Firefly storage upload response: {"images":[{"id":"uuid"}]}. + */ +export function parseAdobeStorageUploadResponse(body: unknown): string { + if (!body || typeof body !== "object") return ""; + const images = (body as Record).images; + if (Array.isArray(images) && images.length > 0) { + const first = images[0]; + if (first && typeof first === "object") { + const id = (first as Record).id; + if (typeof id === "string" && id.trim()) return id.trim(); + } + } + const id = (body as Record).id; + if (typeof id === "string" && id.trim()) return id.trim(); + return ""; +} + +/** + * Upload one image to Firefly storage → blob id for referenceBlobs. + * Wire: POST https://firefly-3p.ff.adobe.io/v2/storage/image (raw bytes). + */ +export async function uploadAdobeFireflyImage(opts: { + accessToken: string; + bytes: Buffer | Uint8Array; + contentType?: string; + sessionCookie?: string; + /** Reuse the same ARP as generate-async (browser does). */ + arpSessionId?: string; + /** Used for deterministic x-nonce (optional). */ + prompt?: string; + fetchImpl?: typeof fetch; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; +}): Promise { + const fetchImpl = opts.fetchImpl || fetch; + const buffer = Buffer.isBuffer(opts.bytes) ? opts.bytes : Buffer.from(opts.bytes); + if (!buffer.length) { + throw new AdobeFireflyError("Cannot upload empty image", 400, "bad_image"); + } + if (buffer.length > ADOBE_FIREFLY_MAX_UPLOAD_BYTES) { + throw new AdobeFireflyError( + `Image reference too large (${buffer.length} bytes; max ${ADOBE_FIREFLY_MAX_UPLOAD_BYTES})`, + 400, + "bad_image" + ); + } + + const sessionCookie = String(opts.sessionCookie || "").trim(); + const cookieHeader = extractAdobeCookieHeader(sessionCookie); + // One ARP for the whole chain — do not mint a new synthetic id per upload. + const arpSessionId = + (opts.arpSessionId && String(opts.arpSessionId).trim()) || + resolveAdobeArpSessionId(cookieHeader || sessionCookie); + const contentType = + (opts.contentType && opts.contentType.trim()) || + (buffer[0] === 0xff && buffer[1] === 0xd8 + ? "image/jpeg" + : buffer[0] === 0x89 && buffer[1] === 0x50 + ? "image/png" + : "image/png"); + + const resp = await fetchImpl(ADOBE_FIREFLY_IMAGE_UPLOAD_URL, { + method: "POST", + headers: buildAdobeUploadHeaders(opts.accessToken, contentType, { + arpSessionId, + cookie: cookieHeader || undefined, + prompt: opts.prompt || "upload", + }), + body: buffer, + }); + + const text = await resp.text().catch(() => ""); + if (resp.status === 401 || resp.status === 403) { + throw new AdobeFireflyError( + "Adobe Firefly image upload unauthorized — paste a fresh IMS JWT", + 401, + "auth" + ); + } + if (!resp.ok) { + throw new AdobeFireflyError( + `Adobe Firefly image upload failed (${resp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`, + resp.status >= 400 && resp.status < 500 ? resp.status : 502, + "upload" + ); + } + + let json: unknown = {}; + try { + json = text ? JSON.parse(text) : {}; + } catch { + throw new AdobeFireflyError( + "Adobe Firefly image upload returned non-JSON body", + 502, + "upload" + ); + } + const id = parseAdobeStorageUploadResponse(json); + if (!id) { + throw new AdobeFireflyError( + "Adobe Firefly image upload succeeded but no images[].id was returned", + 502, + "upload" + ); + } + opts.log?.info?.("ADOBE-FIREFLY", `uploaded reference image id=${id} (${buffer.length} bytes)`); + return id; +} + +/** + * Resolve Media/OpenAI body image fields → Firefly storage blob ids. + * - data: URLs / raw base64 → upload + * - http(s) URLs → fetch then upload + * - already looks like a UUID blob id → use as-is (advanced) + */ +export async function resolveAdobeSourceImageIds(opts: { + accessToken: string; + body: unknown; + max?: number; + sessionCookie?: string; + /** Shared ARP for upload+generate (required for stable Firefly 3P). */ + arpSessionId?: string; + prompt?: string; + fetchImpl?: typeof fetch; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; +}): Promise { + const max = Math.max(1, Math.min(8, opts.max ?? 4)); + const sources = extractAdobeSourceImageSources(opts.body, max); + if (!sources.length) return []; + + const fetchImpl = opts.fetchImpl || fetch; + const ids: string[] = []; + // One ARP for all uploads in this request (browser reuses the same header). + const arpSessionId = + (opts.arpSessionId && String(opts.arpSessionId).trim()) || + resolveAdobeArpSessionId(opts.sessionCookie); + + for (const src of sources) { + // Already a Firefly storage id (uuid) + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(src)) { + ids.push(src); + continue; + } + + let buffer: Buffer; + let contentType = "image/png"; + + if (/^https?:\/\//i.test(src)) { + const r = await fetchImpl(src, { + method: "GET", + headers: { accept: "image/*,*/*" }, + }); + if (!r.ok) { + throw new AdobeFireflyError( + `Failed to download reference image (${r.status}): ${src.slice(0, 120)}`, + 400, + "bad_image" + ); + } + const ab = await r.arrayBuffer(); + buffer = Buffer.from(ab); + const ct = r.headers.get("content-type") || ""; + if (ct.toLowerCase().startsWith("image/")) { + contentType = ct.split(";")[0]!.trim(); + } + } else { + const parsed = parseAdobeImageSourceBytes(src); + buffer = parsed.buffer; + contentType = parsed.contentType; + } + + const id = await uploadAdobeFireflyImage({ + accessToken: opts.accessToken, + bytes: buffer, + contentType, + sessionCookie: opts.sessionCookie, + arpSessionId, + prompt: opts.prompt, + fetchImpl, + log: opts.log, + }); + ids.push(id); + } + + return ids; +} + +/** Transient Adobe 3P overload / rate / edge errors worth retrying. */ +export function isAdobeTransientSubmitError(status: number, bodyText: string): boolean { + if (status === 408 || status === 429 || status === 502 || status === 503 || status === 504) { + return true; + } + const t = (bodyText || "").toLowerCase(); + return ( + t.includes("timeout_error") || + t.includes("system under load") || + t.includes("try again") || + t.includes("temporarily") || + t.includes("overloaded") + ); +} + +export function buildAdobePollHeaders(accessToken: string): Record { + // Live adobe/status_check.txt: Bearer + accept only (no x-api-key, no Cookie). + return { + Authorization: `Bearer ${accessToken}`, + accept: "*/*", + "cache-control": "no-cache", + pragma: "no-cache", + "user-agent": DEFAULT_USER_AGENT, + referer: FIREFLY_REFERER, + }; +} + +export function buildAdobeBalanceHeaders(accessToken: string): Record { + const accountId = extractAdobeAccountIdFromToken(accessToken); + const headers: Record = { + ...browserHeaders(), + Authorization: `Bearer ${accessToken}`, + accept: "application/json", + "content-type": "application/json", + "x-api-key": adobeFireflyBalanceApiKey(), + }; + if (accountId) headers["x-account-id"] = accountId; + return headers; +} + +export function buildAdobeDiscoveryHeaders(accessToken: string): Record { + return { + ...browserHeaders(), + Authorization: `Bearer ${accessToken}`, + "x-api-key": adobeFireflyApiKey(), + "content-type": "application/json", + // Missing Accept → HTTP 406 "Unsupported Accept Type or not allowed". + accept: "*/*", + }; +} + +/** User-facing message when Adobe colligo returns 408 "system under load". */ +export function formatAdobeSystemUnderLoadError( + kind: "image" | "video", + attempts: number, + opts?: { hadBrowserArp?: boolean } +): string { + const hadArp = opts?.hadBrowserArp === true; + if (!hadArp) { + return ( + `Adobe Firefly ${kind} generation failed (HTTP 408 "system under load", after ${attempts} attempt` + + `${attempts === 1 ? "" : "s"}). Your credential is missing a browser x-arp-session-id / sherlockToken ` + + `(JWT alone almost always 408s even when credits/Limits work). Re-open the Adobe Firefly account and paste ` + + `TWO lines from a SUCCESSFUL firefly-3p.ff.adobe.io generate-async request (F12 → Network): ` + + `(1) Authorization token AFTER "Bearer " (eyJ… JWT), (2) the raw x-arp-session-id header value ` + + `OR Cookie containing sherlockToken. Use the multi-line credential box so both lines are kept.` + ); + } + return ( + `Adobe Firefly ${kind} generation failed (HTTP 408 "system under load", after ${attempts} attempt` + + `${attempts === 1 ? "" : "s"}). JWT was accepted for balance/discovery but colligo rejected the risk session ` + + `(Forter/Arkose stale or rate-limited). The app spaces submits, sticks to the last working x-arp-session-id, ` + + `and on 408 auto-warms a fresh Forter/ARP via off-screen headed Chrome (not headless — colligo rejects that). ` + + `Paste the full firefly.adobe.com Cookie once with the JWT so recovery can run. If it still fails after that, ` + + `open firefly.adobe.com, generate one image in-browser, then paste a FRESH multi-line credential (JWT + Cookie) once.` + ); +} + +export function extractAdobeResultLink( + headers: Headers | Record, + body: unknown +): string { + const get = (name: string): string => { + if (typeof (headers as Headers).get === "function") { + return String((headers as Headers).get(name) || "").trim(); + } + const rec = headers as Record; + const key = Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase()); + return String((key ? rec[key] : "") || "").trim(); + }; + + const override = get("x-override-status-link"); + if (override) return override; + + const data = body && typeof body === "object" ? (body as Record) : {}; + const links = data.links && typeof data.links === "object" ? (data.links as Record) : {}; + const result = links.result; + if (typeof result === "string" && result) return result; + if (result && typeof result === "object") { + const href = (result as Record).href; + if (typeof href === "string" && href) return href; + } + if (typeof data.statusUrl === "string" && data.statusUrl) return data.statusUrl; + if (typeof data.resultUrl === "string" && data.resultUrl) return data.resultUrl; + return ""; +} + +/** + * Rewrite Firefly EPO result links to the BKS poll endpoint used by the SPA. + * + * Live capture (adobe/status_check.txt): + * links.result = https://firefly-epo855232.adobe.io/jobs/result/{jobId} + * poll URL = https://bks-epo8552.adobe.io/v2/jobs/result/{jobId}?host=firefly-epo855232.adobe.io + * + * BKS host uses the first 4 digits of the EPO id when the id is longer (855232 → 8552). + */ +export function normalizeAdobePollUrl(rawUrl: string): string { + const url = String(rawUrl || "").trim(); + if (!url) return url; + try { + const parsed = new URL(url); + const host = parsed.hostname.toLowerCase(); + if (!host.startsWith("firefly-epo")) return url; + + const path = parsed.pathname || ""; + const isJobPath = + path.includes("/jobs/result/") || + path.includes("/v2/status") || + path.includes("/status/"); + if (!isJobPath) return url; + + const jobId = path.split("/").filter(Boolean).pop() || ""; + if (!jobId || jobId === "status" || jobId === "result") return url; + + const epoId = host.slice("firefly-epo".length).split(".")[0] || ""; + // 855232 → 8552 (browser BKS host); short ids kept as-is. + const bksId = epoId.length > 4 ? epoId.slice(0, 4) : epoId; + return `https://bks-epo${bksId}.adobe.io/v2/jobs/result/${jobId}?host=${host}`; + } catch { + return url; + } +} + +export function extractAdobeMediaUrl( + latest: unknown, + kind: "image" | "video" +): string | null { + const body = latest && typeof latest === "object" ? (latest as Record) : {}; + const outputs = Array.isArray(body.outputs) ? body.outputs : []; + if (outputs.length > 0) { + const first = outputs[0] && typeof outputs[0] === "object" ? (outputs[0] as Record) : {}; + const media = + kind === "image" + ? first.image && typeof first.image === "object" + ? (first.image as Record) + : null + : first.video && typeof first.video === "object" + ? (first.video as Record) + : null; + const url = media && typeof media.presignedUrl === "string" ? media.presignedUrl : null; + if (url) return url; + } + + // Fallback recursive search for a presigned URL. + const found = findPresignedUrl(latest, kind === "image" ? [".png", ".jpg", ".jpeg", ".webp"] : [".mp4", ".webm"]); + return found; +} + +function findPresignedUrl(obj: unknown, exts: string[]): string | null { + if (!obj) return null; + if (typeof obj === "string") { + const s = obj.trim(); + if (/^https?:\/\//i.test(s) && (exts.some((e) => s.toLowerCase().includes(e)) || s.includes("presigned") || s.includes("X-Amz"))) { + return s; + } + return null; + } + if (Array.isArray(obj)) { + for (const item of obj) { + const found = findPresignedUrl(item, exts); + if (found) return found; + } + return null; + } + if (typeof obj === "object") { + const rec = obj as Record; + if (typeof rec.presignedUrl === "string" && rec.presignedUrl) return rec.presignedUrl; + for (const value of Object.values(rec)) { + const found = findPresignedUrl(value, exts); + if (found) return found; + } + } + return null; +} + +export function isAdobeJobInProgress(status: string): boolean { + const s = String(status || "").toUpperCase(); + return ( + !s || + s === "IN_PROGRESS" || + s === "PENDING" || + s === "RUNNING" || + s === "QUEUED" || + s === "PROCESSING" || + s === "SUBMITTED" + ); +} + +export function isAdobeJobFailed(status: string): boolean { + const s = String(status || "").toUpperCase(); + return s === "FAILED" || s === "CANCELLED" || s === "ERROR" || s === "CANCELED"; +} + +type ImsTokenResponse = { + access_token?: string; + account_type?: string; + guestId?: string; + token_type?: string; + error?: string; + error_description?: string; +}; + +async function imsCheckToken(opts: { + cookie: string; + clientId: string; + guestAllowed: boolean; + fetchImpl: typeof fetch; +}): Promise< + | { ok: true; token: string; data: ImsTokenResponse } + | { ok: false; status: number; error: string } +> { + const form = new URLSearchParams({ + client_id: opts.clientId, + scope: ADOBE_FIREFLY_IMS_SCOPE, + guest_allowed: opts.guestAllowed ? "true" : "false", + }); + + const resp = await opts.fetchImpl(ADOBE_FIREFLY_IMS_REFRESH_URL, { + method: "POST", + headers: { + Accept: "*/*", + "Accept-Language": "en-US,en;q=0.9", + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + Cookie: opts.cookie, + Origin: FIREFLY_ORIGIN, + Referer: FIREFLY_REFERER, + "User-Agent": DEFAULT_USER_AGENT, + }, + body: form.toString(), + }); + + const text = await resp.text().catch(() => ""); + let data: ImsTokenResponse | null = null; + try { + data = JSON.parse(text) as ImsTokenResponse; + } catch { + data = null; + } + + if (!resp.ok) { + return { + ok: false, + status: resp.status, + error: sanitizeErrorMessage( + data?.error_description || data?.error || text.slice(0, 200) || `HTTP ${resp.status}` + ), + }; + } + + const token = String(data?.access_token || "").trim(); + if (!token) { + return { + ok: false, + status: 401, + error: sanitizeErrorMessage( + data?.error_description || data?.error || "IMS response missing access_token" + ), + }; + } + return { ok: true, token, data: data || {} }; +} + +/** + * Exchange a browser Cookie header for an Adobe IMS **user** access_token. + * + * Live repro (user firefly.adobe.com Cookie export): + * - guest_allowed=true → account_type=guest (no AdobeID) → generate 401 / balance 403 + * - guest_allowed=false → "All session cookies are empty" (IMS cookies live on adobelogin.com) + * + * Reliable path: paste Authorization Bearer JWT from a live firefly-3p request. + */ +export async function exchangeAdobeCookieForAccessToken( + cookieHeader: string, + fetchImpl: typeof fetch = fetch +): Promise { + const cookie = String(cookieHeader || "").trim(); + if (!cookie) { + throw new AdobeFireflyError("Adobe Firefly cookie is empty", 401, "missing_cookie"); + } + + // HAR / mixed paste that already contains a user JWT + const embedded = extractAdobeCredentialToken(cookie); + if (embedded !== cookie && looksLikeAdobeJwt(embedded)) { + if (isAdobeGuestAccessToken(embedded)) { + throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token"); + } + return embedded; + } + + const clientIds = [adobeFireflyApiKey(), adobeFireflyExpressClientId()].filter( + (id, i, arr) => id && arr.indexOf(id) === i + ); + + let sawEmptySession = false; + let lastError = ""; + let lastStatus = 502; + let guestTokenSeen = false; + + for (const clientId of clientIds) { + // 1) Authenticated session only (needs IMS cookies from adobelogin.com) + const authed = await imsCheckToken({ + cookie, + clientId, + guestAllowed: false, + fetchImpl, + }); + if (authed.ok) { + if ( + isAdobeGuestAccessToken(authed.token) || + authed.data.account_type === "guest" || + authed.data.guestId + ) { + guestTokenSeen = true; + } else { + return authed.token; + } + } else { + lastStatus = authed.status; + lastError = authed.error; + if (/session cookies are empty/i.test(authed.error)) sawEmptySession = true; + } + + // 2) Guest path — never accept guest tokens for Firefly media/limits + const guest = await imsCheckToken({ + cookie, + clientId, + guestAllowed: true, + fetchImpl, + }); + if (guest.ok) { + if ( + guest.data.account_type === "guest" || + guest.data.guestId || + isAdobeGuestAccessToken(guest.token) + ) { + guestTokenSeen = true; + lastError = "IMS returned a guest token (no AdobeID session)"; + lastStatus = 401; + continue; + } + return guest.token; + } + lastStatus = guest.status; + lastError = guest.error; + if (/session cookies are empty/i.test(guest.error)) sawEmptySession = true; + } + + if (guestTokenSeen || sawEmptySession) { + throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token"); + } + + throw new AdobeFireflyError( + `Adobe IMS token exchange failed (${lastStatus}): ${lastError || "no access_token"}. ${GUEST_COOKIE_HELP}`, + lastStatus === 401 || lastStatus === 403 ? 401 : 502, + "ims_refresh_failed" + ); +} + +/** + * Resolve credentials into a usable **user** IMS access token (rejects guest tokens). + */ +export async function resolveAdobeAccessToken( + credentials: + | { + apiKey?: string; + accessToken?: string; + providerSpecificData?: { cookie?: unknown; access_token?: unknown; accessToken?: unknown } | null; + } + | null + | undefined, + fetchImpl: typeof fetch = fetch +): Promise { + const psd = credentials?.providerSpecificData; + const candidates: string[] = []; + const push = (v: unknown) => { + if (typeof v === "string" && v.trim()) candidates.push(v.trim()); + }; + push(credentials?.apiKey); + push(credentials?.accessToken); + push(psd?.access_token); + push(psd?.accessToken); + push(psd?.cookie); + + if (candidates.length === 0) { + throw new AdobeFireflyError( + "Adobe Firefly credentials missing. " + GUEST_COOKIE_HELP, + 401, + "missing_credentials" + ); + } + + for (const c of candidates) { + const extracted = extractAdobeCredentialToken(c); + if (looksLikeAdobeJwt(extracted) && isAdobeUserAccessToken(extracted)) { + return extracted; + } + } + + for (const c of candidates) { + const extracted = extractAdobeCredentialToken(c); + if (looksLikeAdobeJwt(extracted) && isAdobeGuestAccessToken(extracted)) { + throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token"); + } + } + + const cookieBlob = + candidates.find( + (c) => + c.includes(";") || + c.toLowerCase().includes("aux_sid") || + c.toLowerCase().includes("ff_session") + ) || candidates[0]; + + const token = await exchangeAdobeCookieForAccessToken(cookieBlob, fetchImpl); + if (isAdobeGuestAccessToken(token)) { + throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token"); + } + return token; +} + +// ── Credits balance (Limits) ──────────────────────────────────────────────── + +export interface AdobeFireflyCreditsBalance { + total: number; + used: number; + remaining: number; + availableUntil: string | null; + freeTotal: number; + freeUsed: number; + freeRemaining: number; + planTotal: number; + planUsed: number; + planRemaining: number; + raw?: unknown; +} + +function readQuotaBlock(block: unknown): { total: number; used: number; available: number } { + if (!block || typeof block !== "object") return { total: 0, used: 0, available: 0 }; + const q = + (block as Record).quota && + typeof (block as Record).quota === "object" + ? ((block as Record).quota as Record) + : (block as Record); + const total = Number(q.total ?? 0); + const used = Number(q.used ?? 0); + const available = Number(q.available ?? Math.max(0, total - used)); + return { + total: Number.isFinite(total) ? total : 0, + used: Number.isFinite(used) ? used : 0, + available: Number.isFinite(available) ? available : 0, + }; +} + +/** + * Parse GET /v1/credits/balance JSON (adobe/balance.txt Response). + * total.quota = aggregate; credits.firefly_* = free + plan buckets. + */ +export function parseAdobeCreditsBalance(body: unknown): AdobeFireflyCreditsBalance { + const root = body && typeof body === "object" ? (body as Record) : {}; + const totalBlock = readQuotaBlock(root.total); + const credits = + root.credits && typeof root.credits === "object" + ? (root.credits as Record) + : {}; + const free = readQuotaBlock(credits.firefly_free_credit); + const plan = readQuotaBlock(credits.firefly_plan_credit); + + // Prefer top-level total; fall back to free+plan sum when total missing. + let total = totalBlock.total; + let used = totalBlock.used; + let remaining = totalBlock.available; + if (total <= 0 && (free.total > 0 || plan.total > 0)) { + total = free.total + plan.total; + used = free.used + plan.used; + remaining = free.available + plan.available; + } + if (remaining <= 0 && total > 0) remaining = Math.max(0, total - used); + + const availableUntil = + root.total && + typeof root.total === "object" && + typeof (root.total as Record).availableUntil === "string" + ? String((root.total as Record).availableUntil) + : null; + + return { + total, + used, + remaining, + availableUntil, + freeTotal: free.total, + freeUsed: free.used, + freeRemaining: free.available, + planTotal: plan.total, + planUsed: plan.used, + planRemaining: plan.available, + raw: body, + }; +} + +export async function fetchAdobeCreditsBalance( + accessToken: string, + fetchImpl: typeof fetch = fetch +): Promise { + const resp = await fetchImpl(ADOBE_FIREFLY_CREDITS_BALANCE_URL, { + method: "GET", + headers: buildAdobeBalanceHeaders(accessToken), + }); + if (resp.status === 401 || resp.status === 403) { + throw new AdobeFireflyError("Adobe Firefly balance: token invalid or expired", 401, "auth"); + } + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new AdobeFireflyError( + `Adobe Firefly balance failed (${resp.status}): ${sanitizeErrorMessage(text.slice(0, 200))}`, + 502 + ); + } + const data = await resp.json().catch(() => ({})); + return parseAdobeCreditsBalance(data); +} + +// ── Models discovery ──────────────────────────────────────────────────────── + +export interface AdobeFireflyDiscoveredModel { + modelId: string; + modelVersion: string; + displayName: string; + modality: "image" | "video" | "audio" | "unknown"; + enabled: boolean; + healthStatus?: string; +} + +/** + * Parse POST /v2/models/discovery response into flat model/version rows. + */ +export function parseAdobeModelsDiscovery(body: unknown): AdobeFireflyDiscoveredModel[] { + const root = body && typeof body === "object" ? (body as Record) : {}; + const models = Array.isArray(root.models) ? root.models : []; + const out: AdobeFireflyDiscoveredModel[] = []; + + for (const m of models) { + if (!m || typeof m !== "object") continue; + const rec = m as Record; + const modelId = String(rec.modelId || "").trim(); + if (!modelId) continue; + const versions = + rec.modelVersions && typeof rec.modelVersions === "object" + ? (rec.modelVersions as Record) + : {}; + for (const [ver, spec] of Object.entries(versions)) { + if (!spec || typeof spec !== "object") continue; + const s = spec as Record; + if (s.enabled === false) continue; + const mods = Array.isArray(s.outputModality) + ? s.outputModality.map((x) => String(x).toLowerCase()) + : []; + let modality: AdobeFireflyDiscoveredModel["modality"] = "unknown"; + if (mods.includes("image")) modality = "image"; + else if (mods.includes("video")) modality = "video"; + else if (mods.includes("audio")) modality = "audio"; + out.push({ + modelId, + modelVersion: ver, + displayName: String(s.modelDisplayName || s.modelCaiDisplayName || ver), + modality, + enabled: s.enabled !== false, + healthStatus: typeof s.healthStatus === "string" ? s.healthStatus : undefined, + }); + } + } + return out; +} + +export async function discoverAdobeFireflyModels( + accessToken: string, + fetchImpl: typeof fetch = fetch +): Promise { + const resp = await fetchImpl(ADOBE_FIREFLY_MODELS_DISCOVERY_URL, { + method: "POST", + headers: buildAdobeDiscoveryHeaders(accessToken), + body: JSON.stringify({ filters: { resolveSchema: true } }), + }); + if (resp.status === 401 || resp.status === 403) { + throw new AdobeFireflyError("Adobe Firefly model discovery: token invalid or expired", 401, "auth"); + } + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new AdobeFireflyError( + `Adobe Firefly model discovery failed (${resp.status}): ${sanitizeErrorMessage(text.slice(0, 200))}`, + 502 + ); + } + const data = await resp.json().catch(() => ({})); + return parseAdobeModelsDiscovery(data); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function pollAdobeJob(opts: { + pollUrl: string; + accessToken: string; + kind: "image" | "video"; + timeoutMs: number; + pollIntervalMs?: number; + fetchImpl?: typeof fetch; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; +}): Promise<{ mediaUrl: string; latest: unknown }> { + const fetchImpl = opts.fetchImpl || fetch; + const deadline = Date.now() + opts.timeoutMs; + const interval = opts.pollIntervalMs && opts.pollIntervalMs > 0 ? opts.pollIntervalMs : DEFAULT_POLL_INTERVAL_MS; + let attempt = 0; + let latest: unknown = {}; + + while (Date.now() < deadline) { + attempt += 1; + const pollResp = await fetchImpl(opts.pollUrl, { + method: "GET", + headers: buildAdobePollHeaders(opts.accessToken), + }); + + if (pollResp.status === 401 || pollResp.status === 403) { + const accessError = pollResp.headers.get("x-access-error") || ""; + if (accessError === "taste_exhausted") { + throw new AdobeFireflyError("Adobe Firefly quota exhausted for this account", 429, "quota_exhausted"); + } + throw new AdobeFireflyError("Adobe Firefly token invalid or expired", 401, "auth"); + } + + if (!pollResp.ok) { + const text = await pollResp.text().catch(() => ""); + if ( + pollResp.status === 408 || + pollResp.status === 429 || + pollResp.status === 451 || + pollResp.status >= 500 || + isAdobeTransientSubmitError(pollResp.status, text) + ) { + opts.log?.info?.("ADOBE-FIREFLY", `poll temporary ${pollResp.status}, attempt #${attempt}`); + await sleep(interval); + continue; + } + throw new AdobeFireflyError( + `Adobe Firefly poll failed (${pollResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`, + 502 + ); + } + + latest = await pollResp.json().catch(() => ({})); + const statusHeader = String(pollResp.headers.get("x-task-status") || "").toUpperCase(); + const statusVal = String( + (latest && typeof latest === "object" ? (latest as Record).status : "") || + statusHeader || + "" + ).toUpperCase(); + + const mediaUrl = extractAdobeMediaUrl(latest, opts.kind); + if (mediaUrl) { + return { mediaUrl, latest }; + } + + if (isAdobeJobFailed(statusVal)) { + throw new AdobeFireflyError( + `Adobe Firefly ${opts.kind} job failed: ${sanitizeErrorMessage(JSON.stringify(latest).slice(0, 300))}`, + 502, + "job_failed" + ); + } + + opts.log?.info?.("ADOBE-FIREFLY", `${opts.kind} pending #${attempt} status=${statusVal || "unknown"}`); + await sleep(interval); + } + + throw new AdobeFireflyError(`Adobe Firefly ${opts.kind} generation timed out`, 504, "timeout"); +} + +// Colligo often returns instant 408 with x-colligo-timeout:0.0 under load OR when +// generate-async is hammered in a batch. Space submits (gate) + reuse sticky ARP; +// do NOT thrash synthetic rebuilds on every retry (identical forter → no-op). +// More attempts: 1–2 reuse sticky ARP when forter is fresh; stale forter / attempt 3+ → off-screen Chrome warm. +const SUBMIT_MAX_ATTEMPTS = 5; +/** Base backoff after 408; combined with withAdobeFireflySubmitGate (~12s min gap). */ +function submitBaseDelayMs(): number { + if (process.env.ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS != null && process.env.ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS !== "") { + return Math.max(0, Number(process.env.ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS) || 0); + } + if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT) return 20; + return 8000; +} + +export async function adobeFireflyGenerateImage(opts: { + accessToken: string; + prompt: string; + model: string; + size?: unknown; + aspectRatio?: unknown; + quality?: unknown; + seed?: number; + sourceImageIds?: string[]; + negativePrompt?: string; + /** Optional Cookie blob — used only to lift sherlockToken → x-arp-session-id */ + sessionCookie?: string; + /** Shared ARP (sid+ark+ftr). Reuse with uploads; do not mint per retry. */ + arpSessionId?: string; + /** Session cache key from ensureAdobeFireflySession — sticky ARP across batch jobs. */ + sessionFingerprint?: string; + timeoutMs?: number; + fetchImpl?: typeof fetch; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; +}): Promise<{ url: string; b64_json?: string; latest: unknown }> { + const fetchImpl = opts.fetchImpl || fetch; + const { spec } = resolveAdobeImageModel(opts.model); + const aspectRatio = normalizeAdobeAspectRatio(opts.aspectRatio ?? opts.size, "1:1"); + const outputResolution = normalizeAdobeOutputResolution(opts.quality, opts.size); + const payload = buildAdobeImagePayload({ + prompt: opts.prompt, + aspectRatio, + outputResolution, + modelSpec: spec, + quality: opts.quality, + seed: opts.seed, + sourceImageIds: opts.sourceImageIds, + negativePrompt: opts.negativePrompt, + }); + + const sessionCookie = String(opts.sessionCookie || "").trim(); + const cookieHeader = extractAdobeCookieHeader(sessionCookie); + // Prefer real browser sherlockToken / cookie rebuild (forter+arkose). Only the raw + // credential paste counts as "browser ARP" — never the pure synthetic fallback. + const hadBrowserArp = hasBrowserAdobeArpSession(cookieHeader || sessionCookie); + let arpSessionId = + (opts.arpSessionId && String(opts.arpSessionId).trim()) || + resolveAdobeArpSessionId(cookieHeader || sessionCookie); + let submitData: unknown = {}; + let submitHeaders: Headers | Record = new Headers(); + let lastSubmitError = ""; + let sawSystemUnderLoad = false; + let accessToken = opts.accessToken; + + const { + withAdobeFireflySubmitGate, + markAdobeFireflyArpSuccess, + noteAdobeFireflySubmitFailure, + rotateAdobeFireflySessionOnError, + resolveAdobeArpSessionIdSmart, + fingerprintAdobeCredential, + } = await import("./adobeFireflySession.ts"); + + // Stable sticky key — do NOT include arpSessionId (it changes and would break sticky). + const fingerprint = + String(opts.sessionFingerprint || "").trim() || + fingerprintAdobeCredential([accessToken, cookieHeader || sessionCookie].filter(Boolean).join("\n")); + + // Gate serialize + min gap: mid-batch 408 is often rate-limit thrash, not "dead" ARP. + await withAdobeFireflySubmitGate(async () => { + for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) { + const submitResp = await fetchImpl(ADOBE_FIREFLY_IMAGE_SUBMIT_URL, { + method: "POST", + headers: buildAdobeSubmitHeaders(accessToken, { + arpSessionId, + prompt: opts.prompt, + cookie: cookieHeader || undefined, + }), + body: JSON.stringify(payload), + }); + + if (submitResp.status === 401 || submitResp.status === 403) { + noteAdobeFireflySubmitFailure(); + const accessError = submitResp.headers.get("x-access-error") || ""; + if (accessError === "taste_exhausted") { + throw new AdobeFireflyError( + "Adobe Firefly quota exhausted for this account", + 429, + "quota_exhausted" + ); + } + throw new AdobeFireflyError( + "Adobe Firefly token invalid or expired. Paste a fresh IMS JWT (Authorization: Bearer on firefly-3p) " + + "plus the firefly.adobe.com Cookie once — the app will auto-refresh ARP after that.", + 401, + "auth" + ); + } + + if (!submitResp.ok) { + const text = await submitResp.text().catch(() => ""); + if (isAdobeTransientSubmitError(submitResp.status, text)) { + sawSystemUnderLoad = true; + } + lastSubmitError = `Adobe Firefly image submit failed (${submitResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`; + if (isAdobeTransientSubmitError(submitResp.status, text) && attempt < SUBMIT_MAX_ATTEMPTS) { + try { + if (cookieHeader || sessionCookie) { + const rotated = await rotateAdobeFireflySessionOnError( + { + accessToken, + cookie: cookieHeader || sessionCookie, + arpSessionId, + tokenExpiresAt: 0, + updatedAt: Date.now(), + fingerprint, + source: "rebuild", + }, + { + // Stale forter warms immediately; fresh forter quiet-reuses on 1–2 then warms. + attempt, + tryBrowser: process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0", + log: opts.log, + } + ); + accessToken = rotated.accessToken || accessToken; + arpSessionId = rotated.arpSessionId; + } else { + arpSessionId = resolveAdobeArpSessionIdSmart(sessionCookie, { rotate: true }); + } + } catch { + // Keep prior ARP — synthetic thrash rarely recovers colligo 408. + } + const base = submitBaseDelayMs(); + const delay = + base <= 50 + ? base + : Math.min(90_000, base * Math.pow(2, attempt - 1)) + Math.floor(Math.random() * 1500); + opts.log?.info?.( + "ADOBE-FIREFLY", + `image submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms (recovery attempt=${attempt})` + ); + await sleep(delay); + continue; + } + noteAdobeFireflySubmitFailure(); + if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) { + throw new AdobeFireflyError( + formatAdobeSystemUnderLoadError("image", attempt, { hadBrowserArp }), + 408, + "system_under_load" + ); + } + throw new AdobeFireflyError( + lastSubmitError, + submitResp.status >= 400 && submitResp.status < 500 ? submitResp.status : 502 + ); + } + + submitData = await submitResp.json().catch(() => ({})); + submitHeaders = submitResp.headers; + // Sticky: remember ARP that colligo accepted so the next batch image reuses it. + markAdobeFireflyArpSuccess(fingerprint, arpSessionId); + break; + } + }); + + let pollUrl = extractAdobeResultLink(submitHeaders, submitData); + if (!pollUrl) { + if (sawSystemUnderLoad) { + throw new AdobeFireflyError( + formatAdobeSystemUnderLoadError("image", SUBMIT_MAX_ATTEMPTS, { hadBrowserArp }), + 408, + "system_under_load" + ); + } + throw new AdobeFireflyError( + lastSubmitError || "Adobe Firefly image submit succeeded but no poll URL was returned", + 502 + ); + } + pollUrl = normalizeAdobePollUrl(pollUrl); + + const { mediaUrl, latest } = await pollAdobeJob({ + pollUrl, + accessToken, + kind: "image", + timeoutMs: opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_IMAGE_TIMEOUT_MS, + fetchImpl, + log: opts.log, + }); + + return { url: mediaUrl, latest }; +} + +export async function adobeFireflyGenerateVideo(opts: { + accessToken: string; + prompt: string; + model: string; + size?: unknown; + aspectRatio?: unknown; + duration?: unknown; + quality?: unknown; + resolution?: unknown; + seed?: number; + sourceImageIds?: string[]; + negativePrompt?: string; + generateAudio?: boolean; + sessionCookie?: string; + /** Shared ARP (sid+ark+ftr). Reuse with frame uploads. */ + arpSessionId?: string; + /** Session cache key from ensureAdobeFireflySession — sticky ARP across batch jobs. */ + sessionFingerprint?: string; + timeoutMs?: number; + fetchImpl?: typeof fetch; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; +}): Promise<{ url: string; b64_json?: string; format: string; latest: unknown }> { + const fetchImpl = opts.fetchImpl || fetch; + const { spec } = resolveAdobeVideoModel(opts.model); + const aspectRatio = normalizeAdobeAspectRatio(opts.aspectRatio ?? opts.size, "16:9"); + const duration = + typeof opts.duration === "number" + ? opts.duration + : typeof opts.duration === "string" && opts.duration.trim() + ? Number(opts.duration) + : spec.defaultDuration; + const resolution = + typeof opts.resolution === "string" && opts.resolution.trim() + ? opts.resolution + : typeof opts.quality === "string" && /p$/i.test(opts.quality) + ? opts.quality + : spec.defaultResolution; + + const payload = buildAdobeVideoPayload({ + prompt: opts.prompt, + aspectRatio, + duration: Number.isFinite(duration) ? Number(duration) : spec.defaultDuration, + modelSpec: spec, + resolution, + seed: opts.seed, + sourceImageIds: opts.sourceImageIds, + negativePrompt: opts.negativePrompt, + generateAudio: opts.generateAudio, + }); + + const sessionCookie = String(opts.sessionCookie || "").trim(); + const cookieHeader = extractAdobeCookieHeader(sessionCookie); + const hadBrowserArp = hasBrowserAdobeArpSession(cookieHeader || sessionCookie); + let arpSessionId = + (opts.arpSessionId && String(opts.arpSessionId).trim()) || + resolveAdobeArpSessionId(cookieHeader || sessionCookie); + let submitData: unknown = {}; + let submitHeaders: Headers | Record = new Headers(); + let lastSubmitError = ""; + let sawSystemUnderLoad = false; + let accessToken = opts.accessToken; + + const { + withAdobeFireflySubmitGate, + markAdobeFireflyArpSuccess, + noteAdobeFireflySubmitFailure, + rotateAdobeFireflySessionOnError, + resolveAdobeArpSessionIdSmart, + fingerprintAdobeCredential, + } = await import("./adobeFireflySession.ts"); + + const fingerprint = + String(opts.sessionFingerprint || "").trim() || + fingerprintAdobeCredential([accessToken, cookieHeader || sessionCookie].filter(Boolean).join("\n")); + + await withAdobeFireflySubmitGate(async () => { + for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) { + const submitResp = await fetchImpl(ADOBE_FIREFLY_VIDEO_SUBMIT_URL, { + method: "POST", + headers: buildAdobeSubmitHeaders(accessToken, { + arpSessionId, + prompt: opts.prompt, + cookie: cookieHeader || undefined, + }), + body: JSON.stringify(payload), + }); + + if (submitResp.status === 401 || submitResp.status === 403) { + noteAdobeFireflySubmitFailure(); + const accessError = submitResp.headers.get("x-access-error") || ""; + if (accessError === "taste_exhausted") { + throw new AdobeFireflyError( + "Adobe Firefly quota exhausted for this account", + 429, + "quota_exhausted" + ); + } + throw new AdobeFireflyError( + "Adobe Firefly token invalid or expired. Paste a fresh IMS JWT (Authorization: Bearer on firefly-3p) " + + "plus the firefly.adobe.com Cookie once — the app will auto-refresh ARP after that.", + 401, + "auth" + ); + } + + if (!submitResp.ok) { + const text = await submitResp.text().catch(() => ""); + if (isAdobeTransientSubmitError(submitResp.status, text)) { + sawSystemUnderLoad = true; + } + lastSubmitError = `Adobe Firefly video submit failed (${submitResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`; + if (isAdobeTransientSubmitError(submitResp.status, text) && attempt < SUBMIT_MAX_ATTEMPTS) { + try { + if (cookieHeader || sessionCookie) { + const rotated = await rotateAdobeFireflySessionOnError( + { + accessToken, + cookie: cookieHeader || sessionCookie, + arpSessionId, + tokenExpiresAt: 0, + updatedAt: Date.now(), + fingerprint, + source: "rebuild", + }, + { + attempt, + tryBrowser: process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0", + log: opts.log, + } + ); + accessToken = rotated.accessToken || accessToken; + arpSessionId = rotated.arpSessionId; + } else { + arpSessionId = resolveAdobeArpSessionIdSmart(sessionCookie, { rotate: true }); + } + } catch { + /* keep prior ARP */ + } + const base = submitBaseDelayMs(); + const delay = + base <= 50 + ? base + : Math.min(90_000, base * Math.pow(2, attempt - 1)) + Math.floor(Math.random() * 1500); + opts.log?.info?.( + "ADOBE-FIREFLY", + `video submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms (recovery attempt=${attempt})` + ); + await sleep(delay); + continue; + } + noteAdobeFireflySubmitFailure(); + if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) { + throw new AdobeFireflyError( + formatAdobeSystemUnderLoadError("video", attempt, { hadBrowserArp }), + 408, + "system_under_load" + ); + } + throw new AdobeFireflyError( + lastSubmitError, + submitResp.status >= 400 && submitResp.status < 500 ? submitResp.status : 502 + ); + } + + submitData = await submitResp.json().catch(() => ({})); + submitHeaders = submitResp.headers; + markAdobeFireflyArpSuccess(fingerprint, arpSessionId); + break; + } + }); + + let pollUrl = extractAdobeResultLink(submitHeaders, submitData); + if (!pollUrl) { + if (sawSystemUnderLoad) { + throw new AdobeFireflyError( + formatAdobeSystemUnderLoadError("video", SUBMIT_MAX_ATTEMPTS, { hadBrowserArp }), + 408, + "system_under_load" + ); + } + throw new AdobeFireflyError( + lastSubmitError || "Adobe Firefly video submit succeeded but no poll URL was returned", + 502 + ); + } + pollUrl = normalizeAdobePollUrl(pollUrl); + + const { mediaUrl, latest } = await pollAdobeJob({ + pollUrl, + accessToken, + kind: "video", + timeoutMs: opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_VIDEO_TIMEOUT_MS, + fetchImpl, + log: opts.log, + }); + + return { url: mediaUrl, format: "mp4", latest }; +} diff --git a/open-sse/services/adobeFireflySession.ts b/open-sse/services/adobeFireflySession.ts index 18e19c46dd..4e24c0ec80 100644 --- a/open-sse/services/adobeFireflySession.ts +++ b/open-sse/services/adobeFireflySession.ts @@ -1,652 +1,791 @@ -/** - * Adobe Firefly durable session manager. - * - * Goal: user pastes JWT and/or browser Cookie once; we keep generate working by: - * 1) Extracting / caching the IMS user JWT (24h typical) - * 2) Rebuilding x-arp-session-id from live cookie pieces (ff_session_guid + arkose + - * forterToken + optional bfp/fpjs) — the SPA's sherlockToken is just that blob - * 3) Optionally warming forter/arkose via Playwright against firefly.adobe.com - * 4) Merging Set-Cookie / jar updates back into the stored cookie string - * 5) Rotating ARP on colligo 408 retries (stale Arkose/Forter is the usual cause) - * - * Firefly.adobe.com page cookies alone still cannot mint a user IMS token (IMS cookies - * live on adobelogin.com). JWT paste once covers that; ARP is what expires every few - * minutes and must be auto-rebuilt. - */ - -import { createHash, randomUUID } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { - AdobeFireflyError, - buildAdobeArpSessionId, - extractAdobeArpSessionId, - extractAdobeCookieHeader, - extractAdobeCredentialToken, - isAdobeUserAccessToken, - looksLikeAdobeCookieBlob, - looksLikeAdobeJwt, - decodeAdobeJwtPayload, - resolveAdobeAccessToken, - exchangeAdobeCookieForAccessToken, -} from "./adobeFireflyClient.ts"; - -export interface AdobeFireflySession { - accessToken: string; - cookie: string; - arpSessionId: string; - /** Epoch ms when the IMS token is expected to expire (best-effort). */ - tokenExpiresAt: number; - updatedAt: number; - /** Hash of the original credential paste (cache key). */ - fingerprint: string; - source: "paste" | "ims" | "browser" | "cache" | "rebuild"; -} - -export interface AdobeFireflySessionResolveOpts { - credentials?: { - apiKey?: string; - accessToken?: string; - providerSpecificData?: { cookie?: unknown; access_token?: unknown; accessToken?: unknown } | null; - } | null; - /** Force browser / cookie ARP rebuild (e.g. after HTTP 408). */ - forceRefresh?: boolean; - /** Prefer minting a brand-new ARP (retry path). */ - rotateArp?: boolean; - fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void }; - /** Disable Playwright refresh (tests / hosts without browsers). */ - allowBrowserRefresh?: boolean; -} - -const sessionCache = new Map(); -const browserRefreshInFlight = new Map>(); - -/** ARP / sherlock is short-lived; refresh before this age when cookies can rebuild. */ -const ARP_MAX_AGE_MS = 90_000; -/** Refresh IMS token this many ms before JWT expiry. */ -const JWT_REFRESH_SKEW_MS = 10 * 60_000; -/** Persist sessions under DATA_DIR so restarts keep JWT + last cookie. */ -const SESSION_DIR_NAME = "adobe-firefly-sessions"; - -function dataDir(): string { - return ( - String(process.env.DATA_DIR || process.env.OMNIROUTE_DATA_DIR || "").trim() || - join(process.cwd(), ".data") - ); -} - -function sessionFilePath(fingerprint: string): string { - const dir = join(dataDir(), SESSION_DIR_NAME); - try { - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - } catch { - /* ignore */ - } - return join(dir, `${fingerprint}.json`); -} - -export function fingerprintAdobeCredential(raw: string): string { - return createHash("sha256").update(String(raw || "").trim()).digest("hex").slice(0, 32); -} - -/** Pull a single cookie value from a Cookie header / paste blob. */ -export function getAdobeCookieValue(cookieOrBlob: string, name: string): string { - const raw = String(cookieOrBlob || ""); - if (!raw || !name) return ""; - const re = new RegExp(`(?:^|[;\\s\\n\\r])${name.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}=([^;\\s\\n\\r]+)`, "i"); - const m = raw.match(re); - if (!m?.[1]) return ""; - let v = m[1].trim().replace(/^["']|["']$/g, ""); - try { - if (/%[0-9A-Fa-f]{2}/.test(v)) v = decodeURIComponent(v); - } catch { - /* keep */ - } - return v; -} - -/** Normalize Forter token to the live ftr shape ending in -v2_tt. */ -export function normalizeAdobeForterToken(value: string): string { - let f = String(value || "").trim(); - if (!f) return ""; - try { - if (/%[0-9A-Fa-f]{2}/.test(f)) f = decodeURIComponent(f); - } catch { - /* keep */ - } - // Cookie sometimes stores "id,timestamp" (localStorage form) — not usable as ftr. - if (/^[a-f0-9]{32},\d+$/i.test(f)) return ""; - if (f.endsWith("v2") && !f.endsWith("v2_tt")) f = `${f}_tt`; - return f; -} - -/** - * Rebuild x-arp-session-id from browser cookie components. - * Live successful generate-async ARP is base64(JSON({sid, ark, ftr, bfp?, fpjs?})). - * Returns "" when required pieces are missing. - */ -export function buildAdobeArpSessionIdFromCookies( - cookieOrBlob: string, - extras?: { region?: string; bfp?: string; fpjs?: string } -): string { - const blob = String(cookieOrBlob || ""); - if (!blob.trim()) return ""; - - const sid = - getAdobeCookieValue(blob, "ff_session_guid") || - getAdobeCookieValue(blob, "sid") || - ""; - const ark = getAdobeCookieValue(blob, "arkose") || ""; - const ftr = - normalizeAdobeForterToken(getAdobeCookieValue(blob, "forterToken")) || - normalizeAdobeForterToken(getAdobeCookieValue(blob, "forter")) || - ""; - if (!sid || !ark || !ftr) return ""; - - let bfp = extras?.bfp || getAdobeCookieValue(blob, "bfp") || ""; - let fpjsRaw = extras?.fpjs || getAdobeCookieValue(blob, "fpjs") || ""; - if (fpjsRaw) { - try { - if (/%[0-9A-Fa-f]{2}/.test(fpjsRaw)) fpjsRaw = decodeURIComponent(fpjsRaw); - } catch { - /* keep */ - } - } - - // Prefer rebuilding over a stale sherlockToken when cookie pieces exist — - // forterToken timestamps advance as the SPA warms risk SDKs. - const obj: Record = { sid, ark, ftr }; - if (bfp) obj.bfp = bfp; - if (fpjsRaw) obj.fpjs = fpjsRaw; - return Buffer.from(JSON.stringify(obj), "utf-8").toString("base64"); -} - -/** True when the blob can rebuild a full ARP without a pasted sherlockToken. */ -export function canRebuildAdobeArpFromCookies(cookieOrBlob: string): boolean { - return Boolean(buildAdobeArpSessionIdFromCookies(cookieOrBlob)); -} - -/** - * Resolve the best ARP for a request: - * 1) force-rotate → mint fresh synthetic (or rebuild if cookies present) - * 2) rebuild from cookie pieces (forter/arkose/sid) — usually fresher than sherlock - * 3) explicit sherlockToken / x-arp-session-id from paste - * 4) synthetic rich ARP - */ -export function resolveAdobeArpSessionIdSmart( - cookieOrBlob?: string, - opts?: { rotate?: boolean } -): string { - const blob = String(cookieOrBlob || ""); - if (opts?.rotate) { - const rebuilt = buildAdobeArpSessionIdFromCookies(blob); - if (rebuilt) return rebuilt; - return buildAdobeArpSessionId(); - } - const rebuilt = buildAdobeArpSessionIdFromCookies(blob); - const extracted = extractAdobeArpSessionId(blob); - // Prefer rebuild when both exist: cookie forter is updated by the SPA more often - // than the frozen sherlockToken the user pasted minutes ago. - if (rebuilt && extracted) { - const rebuiltFtr = (() => { - try { - const j = JSON.parse(Buffer.from(rebuilt + "=".repeat((4 - (rebuilt.length % 4)) % 4), "base64").toString("utf8")) as { ftr?: string }; - return String(j.ftr || ""); - } catch { - return ""; - } - })(); - const extractedFtr = (() => { - try { - const j = JSON.parse(Buffer.from(extracted + "=".repeat((4 - (extracted.length % 4)) % 4), "base64").toString("utf8")) as { ftr?: string }; - return String(j.ftr || ""); - } catch { - return ""; - } - })(); - // Prefer the ARP whose forter timestamp is newer (…_ms__UDF43…). - const ts = (ftr: string) => { - const m = ftr.match(/_(\d{13})__/); - return m ? Number(m[1]) : 0; - }; - if (ts(rebuiltFtr) >= ts(extractedFtr)) return rebuilt; - return extracted; - } - if (rebuilt) return rebuilt; - if (extracted) return extracted; - return buildAdobeArpSessionId(); -} - -/** Merge cookie name=value pairs (new wins). Single-line Cookie header. */ -export function mergeAdobeCookieHeaders(base: string, updates: string): string { - const map = new Map(); - const ingest = (raw: string) => { - for (const part of String(raw || "").split(";")) { - const idx = part.indexOf("="); - if (idx <= 0) continue; - let name = part.slice(0, idx).trim(); - let value = part.slice(idx + 1).trim(); - if (!name) continue; - try { - name = decodeURIComponent(name); - } catch { - /* keep */ - } - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1); - } - if (/[\r\n\0]/.test(value)) continue; - map.set(name, value); - } - }; - ingest(extractAdobeCookieHeader(base) || base); - ingest(extractAdobeCookieHeader(updates) || updates); - return [...map.entries()].map(([k, v]) => `${k}=${v}`).join("; "); -} - -/** Serialize session back into the multi-line credential paste application stores. */ -export function serializeAdobeFireflyCredential(session: Pick): string { - const lines: string[] = []; - if (session.accessToken) lines.push(session.accessToken.trim()); - if (session.arpSessionId) lines.push(session.arpSessionId.trim()); - if (session.cookie) lines.push(session.cookie.trim()); - return lines.join("\n"); -} - -export function estimateAdobeTokenExpiry(accessToken: string): number { - const payload = decodeAdobeJwtPayload(accessToken); - if (!payload) return Date.now() + 60 * 60_000; - const created = Number(payload.created_at || 0); - const expiresIn = Number(payload.expires_in || 0); - if (created > 0 && expiresIn > 0) return created + expiresIn; - // Fallback: treat as 20h from now if claims missing - return Date.now() + 20 * 60 * 60_000; -} - -function diskSessionsEnabled(): boolean { - // Unit tests and explicit opt-out skip durable disk cache (avoids sticky IMS skips). - if (process.env.ADOBE_FIREFLY_SESSION_DISK === "0") return false; - if (process.env.NODE_ENV === "test") return false; - if (process.env.VITEST || process.env.NODE_TEST_CONTEXT) return false; - return true; -} - -function loadDiskSession(fingerprint: string): AdobeFireflySession | null { - if (!diskSessionsEnabled()) return null; - try { - const path = sessionFilePath(fingerprint); - if (!existsSync(path)) return null; - const raw = readFileSync(path, "utf8"); - const obj = JSON.parse(raw) as AdobeFireflySession; - if (!obj?.accessToken || !isAdobeUserAccessToken(obj.accessToken)) return null; - return { ...obj, fingerprint, source: "cache" }; - } catch { - return null; - } -} - -function saveDiskSession(session: AdobeFireflySession): void { - if (!diskSessionsEnabled()) return; - try { - const path = sessionFilePath(session.fingerprint); - writeFileSync(path, JSON.stringify(session, null, 2), "utf8"); - } catch { - /* best-effort */ - } -} - -function collectCredentialBlobs( - credentials: AdobeFireflySessionResolveOpts["credentials"] -): string[] { - const out: string[] = []; - const push = (v: unknown) => { - if (typeof v === "string" && v.trim()) out.push(v.trim()); - }; - push(credentials?.apiKey); - push(credentials?.accessToken); - push(credentials?.providerSpecificData?.cookie); - push(credentials?.providerSpecificData?.access_token); - push(credentials?.providerSpecificData?.accessToken); - return out; -} - -/** - * Optional Playwright warm-up: open firefly.adobe.com with the user's cookies so - * Forter/Arkose mint fresh tokens, then rebuild ARP + merge the jar. - * Never throws — returns null when Playwright is unavailable or warm-up fails. - */ -export async function refreshAdobeSessionViaBrowser( - session: AdobeFireflySession, - log?: AdobeFireflySessionResolveOpts["log"] -): Promise { - if (process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "0") return null; - - let chromium: typeof import("playwright").chromium | null = null; - try { - const pw = await import("playwright"); - chromium = pw.chromium; - } catch { - log?.warn?.("ADOBE-FIREFLY", "Playwright not available — skip browser ARP refresh"); - return null; - } - - let browser: import("playwright").Browser | null = null; - try { - browser = await chromium.launch({ - headless: true, - args: ["--disable-blink-features=AutomationControlled"], - }); - const context = await browser.newContext({ - userAgent: - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36", - locale: "en-US", - viewport: { width: 1280, height: 800 }, - }); - await context.addInitScript(() => { - Object.defineProperty(navigator, "webdriver", { get: () => undefined }); - }); - - const cookieHeader = extractAdobeCookieHeader(session.cookie) || session.cookie; - for (const part of cookieHeader.split(";")) { - const idx = part.indexOf("="); - if (idx <= 0) continue; - let name = part.slice(0, idx).trim(); - let value = part.slice(idx + 1).trim(); - try { - name = decodeURIComponent(name); - } catch { - /* keep */ - } - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1); - } - if (!name || /[\r\n\0]/.test(value)) continue; - try { - await context.addCookies([ - { name, value, domain: ".adobe.com", path: "/", secure: true, sameSite: "Lax" }, - ]); - } catch { - try { - await context.addCookies([ - { name, value, url: "https://firefly.adobe.com/", path: "/", secure: true }, - ]); - } catch { - /* skip invalid cookie */ - } - } - } - - const page = await context.newPage(); - await page.goto("https://firefly.adobe.com/generate/image", { - waitUntil: "domcontentloaded", - timeout: 45_000, - }); - - // Inject stored user JWT so SPA API calls (if any) use AdobeID, not guest. - if (session.accessToken) { - await page - .evaluate((token) => { - for (const key of Object.keys(sessionStorage)) { - if (!key.includes("adobeid_ims_access_token/clio-playground-web")) continue; - let obj: Record = {}; - try { - obj = JSON.parse(sessionStorage.getItem(key) || "{}") as Record; - } catch { - obj = {}; - } - obj.tokenValue = token; - obj.access_token = token; - obj.valid = true; - obj.expire = Date.now() + 20 * 3600 * 1000; - obj.expires_in = 86400000; - obj.client_id = "clio-playground-web"; - sessionStorage.setItem(key, JSON.stringify(obj)); - } - }, session.accessToken) - .catch(() => {}); - } - - // Wait for Forter / Arkose warm-up. - await page.waitForTimeout(6_000); - - const jar = await context.cookies(); - const jarHeader = jar.map((c) => `${c.name}=${c.value}`).join("; "); - const ls = await page - .evaluate(() => ({ - bfp: localStorage.getItem("bfp") || "", - fpjs: localStorage.getItem("fpjs") || "", - forter: localStorage.getItem("forterToken") || "", - })) - .catch(() => ({ bfp: "", fpjs: "", forter: "" })); - - const mergedCookie = mergeAdobeCookieHeaders(session.cookie, jarHeader); - // Ensure bfp/fpjs land in the cookie blob for rebuild if only in localStorage - let blobForArp = mergedCookie; - if (ls.bfp && !getAdobeCookieValue(blobForArp, "bfp")) { - blobForArp = mergeAdobeCookieHeaders(blobForArp, `bfp=${ls.bfp}`); - } - if (ls.fpjs && !getAdobeCookieValue(blobForArp, "fpjs")) { - blobForArp = mergeAdobeCookieHeaders(blobForArp, `fpjs=${ls.fpjs}`); - } - - const arp = - buildAdobeArpSessionIdFromCookies(blobForArp, { - bfp: ls.bfp || undefined, - fpjs: ls.fpjs || undefined, - }) || - extractAdobeArpSessionId(blobForArp) || - buildAdobeArpSessionId(); - - const next: AdobeFireflySession = { - ...session, - cookie: extractAdobeCookieHeader(blobForArp) || blobForArp, - arpSessionId: arp, - updatedAt: Date.now(), - source: "browser", - }; - sessionCache.set(session.fingerprint, next); - saveDiskSession(next); - log?.info?.("ADOBE-FIREFLY", "browser session warm-up refreshed ARP/cookie"); - return next; - } catch (err) { - log?.warn?.( - "ADOBE-FIREFLY", - `browser ARP refresh failed: ${err instanceof Error ? err.message : String(err)}` - ); - return null; - } finally { - if (browser) { - try { - await browser.close(); - } catch { - /* ignore */ - } - } - } -} - -/** - * Resolve a durable Firefly session from stored credentials. - * Caches in memory + DATA_DIR; rebuilds ARP from cookies; optionally warms via Playwright. - */ -export async function ensureAdobeFireflySession( - opts: AdobeFireflySessionResolveOpts -): Promise { - const blobs = collectCredentialBlobs(opts.credentials); - if (blobs.length === 0) { - throw new AdobeFireflyError( - "Adobe Firefly credentials missing. Paste the IMS JWT (Authorization: Bearer on firefly-3p) " + - "and ideally the full firefly.adobe.com Cookie (with sherlockToken / forterToken / arkose) once.", - 401, - "missing_credentials" - ); - } - - const joined = blobs.join("\n"); - const fingerprint = fingerprintAdobeCredential(joined); - - // forceRefresh / rotate always drop in-memory cache for this fingerprint - if (opts.forceRefresh) sessionCache.delete(fingerprint); - - const cached = sessionCache.get(fingerprint) || loadDiskSession(fingerprint); - if (cached && !opts.forceRefresh) sessionCache.set(fingerprint, cached); - - const fetchImpl = opts.fetchImpl || fetch; - let accessToken = ""; - let cookie = ""; - let pasteHadUserJwt = false; - - // Prefer JWT from the live paste (authoritative for this request) - for (const b of blobs) { - const tok = extractAdobeCredentialToken(b); - if (looksLikeAdobeJwt(tok) && isAdobeUserAccessToken(tok)) { - accessToken = tok; - pasteHadUserJwt = true; - break; - } - } - // Cookie-only paste: use short-lived memory cache JWT only (not a stale disk token alone) - if ( - !accessToken && - cached?.accessToken && - isAdobeUserAccessToken(cached.accessToken) && - sessionCache.has(fingerprint) && - Date.now() - cached.updatedAt < 30 * 60_000 - ) { - accessToken = cached.accessToken; - } - - // Cookie blob - for (const b of blobs) { - const c = extractAdobeCookieHeader(b); - if (c) { - cookie = c; - break; - } - if (looksLikeAdobeCookieBlob(b)) { - cookie = extractAdobeCookieHeader(b) || b; - break; - } - } - if (!cookie && cached?.cookie) cookie = cached.cookie; - if (cached?.cookie && cookie) cookie = mergeAdobeCookieHeaders(cached.cookie, cookie); - - // Cookie-only or near-expiry JWT → try IMS exchange (needs real IMS cookies on adobelogin.com) - const tokenExpiresAt = accessToken ? estimateAdobeTokenExpiry(accessToken) : 0; - const needJwtRefresh = - !accessToken || - !pasteHadUserJwt || - (tokenExpiresAt > 0 && tokenExpiresAt - Date.now() < JWT_REFRESH_SKEW_MS); - - if (needJwtRefresh && cookie) { - try { - const refreshed = await exchangeAdobeCookieForAccessToken(cookie, fetchImpl); - if (isAdobeUserAccessToken(refreshed)) { - accessToken = refreshed; - opts.log?.info?.("ADOBE-FIREFLY", "IMS cookie exchange produced a user JWT"); - } - } catch { - // Fall through — pure firefly cookies still yield guest-only; keep existing JWT. - } - } - - if (!accessToken) { - // Last resort: full resolve path (throws guest_token with help text) - accessToken = await resolveAdobeAccessToken(opts.credentials, fetchImpl); - } - - const arpAge = cached ? Date.now() - cached.updatedAt : Number.POSITIVE_INFINITY; - const shouldRotate = - Boolean(opts.rotateArp) || - Boolean(opts.forceRefresh) || - arpAge > ARP_MAX_AGE_MS || - !cached?.arpSessionId; - - let arpSessionId = shouldRotate - ? resolveAdobeArpSessionIdSmart(cookie || joined, { rotate: true }) - : cached?.arpSessionId || resolveAdobeArpSessionIdSmart(cookie || joined); - - let session: AdobeFireflySession = { - accessToken, - cookie: cookie || extractAdobeCookieHeader(joined) || "", - arpSessionId, - tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), - updatedAt: Date.now(), - fingerprint, - source: shouldRotate ? "rebuild" : cached?.source || "paste", - }; - - // Browser warm-up is OFF by default: headless Forter/Arkose is rejected by colligo (408). - // Enable only with ADOBE_FIREFLY_BROWSER_REFRESH=1 (and forceRefresh / missing ARP pieces). - const allowBrowser = - opts.allowBrowserRefresh === true || process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "1"; - const needsBrowser = - allowBrowser && - Boolean(session.cookie) && - (opts.forceRefresh || - (!canRebuildAdobeArpFromCookies(session.cookie) && !extractAdobeArpSessionId(session.cookie))); - - if (needsBrowser && session.cookie) { - const key = fingerprint; - let inflight = browserRefreshInFlight.get(key); - if (!inflight) { - inflight = refreshAdobeSessionViaBrowser(session, opts.log).finally(() => { - browserRefreshInFlight.delete(key); - }); - browserRefreshInFlight.set(key, inflight); - } - const warmed = await inflight; - if (warmed) session = warmed; - } - - // Final ARP if still empty - if (!session.arpSessionId) { - session.arpSessionId = resolveAdobeArpSessionIdSmart(session.cookie || joined, { - rotate: true, - }); - } - - sessionCache.set(fingerprint, session); - saveDiskSession(session); - return session; -} - -/** - * After a colligo 408: rotate ARP (and optionally warm browser), return next session. - */ -export async function rotateAdobeFireflySessionOnError( - session: AdobeFireflySession, - opts?: { - tryBrowser?: boolean; - log?: AdobeFireflySessionResolveOpts["log"]; - } -): Promise { - let next: AdobeFireflySession = { - ...session, - arpSessionId: resolveAdobeArpSessionIdSmart(session.cookie, { rotate: true }), - updatedAt: Date.now(), - source: "rebuild", - }; - - if (opts?.tryBrowser && session.cookie && process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0") { - const warmed = await refreshAdobeSessionViaBrowser(next, opts.log); - if (warmed) next = warmed; - } - - // Always mint a unique synthetic ARP if rebuild produced the same string - if (next.arpSessionId === session.arpSessionId) { - next.arpSessionId = buildAdobeArpSessionId(); - next.source = "rebuild"; - } - - sessionCache.set(session.fingerprint, next); - saveDiskSession(next); - return next; -} - -/** Test helper — clear in-memory session cache. */ -export function __resetAdobeFireflySessionCacheForTests(): void { - sessionCache.clear(); - browserRefreshInFlight.clear(); -} +/** + * Adobe Firefly durable session manager. + * + * Goal: same as other OmniRoute web-cookie providers (notion-web, perplexity-web): + * paste Cookie (+ optional IMS JWT) once and use pure HTTP — **no browser window**. + * + * 1) Extract / cache IMS user JWT from paste (or short-lived memory/disk cache) + * 2) Rebuild x-arp-session-id from cookie pieces (ff_session_guid + arkose + forterToken) + * or pasted sherlockToken — never launch Chrome by default + * 3) Sticky working ARP across batch jobs + submit spacing (colligo rate-limit defense) + * 4) Optional Chrome warm ONLY when ADOBE_FIREFLY_BROWSER_REFRESH=1 (or mid-batch 408 recovery). + * Default Chrome mode is **off-screen headed** (Forter-safe). Headless is opt-in and often rejected. + */ + +import { createHash, randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + AdobeFireflyError, + buildAdobeArpSessionId, + extractAdobeArpSessionId, + extractAdobeCookieHeader, + extractAdobeCredentialToken, + isAdobeUserAccessToken, + looksLikeAdobeCookieBlob, + looksLikeAdobeJwt, + decodeAdobeJwtPayload, + resolveAdobeAccessToken, + exchangeAdobeCookieForAccessToken, +} from "./adobeFireflyClient.ts"; + +export interface AdobeFireflySession { + accessToken: string; + cookie: string; + arpSessionId: string; + /** Epoch ms when the IMS token is expected to expire (best-effort). */ + tokenExpiresAt: number; + updatedAt: number; + /** Hash of the original credential paste (cache key). */ + fingerprint: string; + source: "paste" | "ims" | "browser" | "cache" | "rebuild"; +} + +export interface AdobeFireflySessionResolveOpts { + credentials?: { + apiKey?: string; + accessToken?: string; + providerSpecificData?: { cookie?: unknown; access_token?: unknown; accessToken?: unknown } | null; + } | null; + /** Force browser / cookie ARP rebuild (e.g. after HTTP 408). */ + forceRefresh?: boolean; + /** Prefer minting a brand-new ARP (retry path). */ + rotateArp?: boolean; + fetchImpl?: typeof fetch; + log?: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void }; + /** Disable Playwright refresh (tests / hosts without browsers). */ + allowBrowserRefresh?: boolean; +} + +const sessionCache = new Map(); +const browserRefreshInFlight = new Map>(); +/** Last ARP that produced HTTP 2xx on generate-async — prefer until colligo 408. */ +const lastWorkingArpByFingerprint = new Map(); +/** Serialize Firefly generate submits + enforce a quiet period (colligo rate-limits look like 408). */ +let adobeSubmitChain: Promise = Promise.resolve(); +let lastAdobeSubmitAt = 0; + +/** Do not thrash rebuilds: a working ARP stays sticky for this long unless 408 clears it. */ +const WORKING_ARP_STICKY_MS = 25 * 60_000; +/** Forter token age above this → consider risk session stale (informational / recovery). */ +const FORTER_STALE_MS = 4 * 60_000; +/** After this many successful submits in a row, add an extra quiet period (colligo batch throttle). */ +const BATCH_SUCCESS_COOLDOWN_EVERY = 3; +const BATCH_SUCCESS_EXTRA_GAP_MS = 15_000; + +let consecutiveAdobeSubmitSuccesses = 0; + +/** Minimum gap between generate-async submits (ms). Prevents batch thrashing → 408. */ +function minSubmitGapMs(): number { + if (process.env.ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS != null && process.env.ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS !== "") { + return Math.max(0, Number(process.env.ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS) || 0); + } + // Unit tests must not serialize multi-second gaps between cases that share the process-global gate. + if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT) return 0; + // Live colligo rejects thrash after a few generates even with sticky ARP — 12s default. + return 12_000; +} + +/** Extra gap after every N successful submits (mid-batch death defense). */ +function batchExtraGapMs(): number { + if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT) return 0; + if (consecutiveAdobeSubmitSuccesses > 0 && consecutiveAdobeSubmitSuccesses % BATCH_SUCCESS_COOLDOWN_EVERY === 0) { + return Number(process.env.ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS || BATCH_SUCCESS_EXTRA_GAP_MS); + } + return 0; +} +/** Refresh IMS token this many ms before JWT expiry. */ +const JWT_REFRESH_SKEW_MS = 10 * 60_000; +/** + * Proactively browser-warm the risk session when the Forter token is older than this. + * Colligo 408s a stale Forter/ARP; warming before the first submit avoids the wasted 408. + * Kept above a single batch's duration so mid-batch requests reuse the sticky working ARP. + */ +const FORTER_PROACTIVE_WARM_MS = 3 * 60_000; + +/** + * Browser Forter-warm is the DEFAULT engine for Adobe Firefly (the only reliable way to + * keep the Forter/Arkose risk session fresh — pure HTTP goes stale and 408s). It stays on + * unless explicitly disabled with ADOBE_FIREFLY_BROWSER_REFRESH=0. The legacy opt-in value + * "1" still enables it; any other value (including unset) now also enables it. + */ +export function adobeFireflyBrowserEnabled(): boolean { + return process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0"; +} +/** Persist sessions under DATA_DIR so restarts keep JWT + last cookie. */ +const SESSION_DIR_NAME = "adobe-firefly-sessions"; + +function dataDir(): string { + return ( + String(process.env.DATA_DIR || process.env.OMNIROUTE_DATA_DIR || "").trim() || + join(process.cwd(), ".data") + ); +} + +function sessionFilePath(fingerprint: string): string { + const dir = join(dataDir(), SESSION_DIR_NAME); + try { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + } catch { + /* ignore */ + } + return join(dir, `${fingerprint}.json`); +} + +export function fingerprintAdobeCredential(raw: string): string { + return createHash("sha256").update(String(raw || "").trim()).digest("hex").slice(0, 32); +} + +/** Pull a single cookie value from a Cookie header / paste blob. */ +export function getAdobeCookieValue(cookieOrBlob: string, name: string): string { + const raw = String(cookieOrBlob || ""); + if (!raw || !name) return ""; + const re = new RegExp(`(?:^|[;\\s\\n\\r])${name.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}=([^;\\s\\n\\r]+)`, "i"); + const m = raw.match(re); + if (!m?.[1]) return ""; + let v = m[1].trim().replace(/^["']|["']$/g, ""); + try { + if (/%[0-9A-Fa-f]{2}/.test(v)) v = decodeURIComponent(v); + } catch { + /* keep */ + } + return v; +} + +/** Normalize Forter token to the live ftr shape ending in -v2_tt. */ +export function normalizeAdobeForterToken(value: string): string { + let f = String(value || "").trim(); + if (!f) return ""; + try { + if (/%[0-9A-Fa-f]{2}/.test(f)) f = decodeURIComponent(f); + } catch { + /* keep */ + } + // Cookie sometimes stores "id,timestamp" (localStorage form) — not usable as ftr. + if (/^[a-f0-9]{32},\d+$/i.test(f)) return ""; + if (f.endsWith("v2") && !f.endsWith("v2_tt")) f = `${f}_tt`; + return f; +} + +/** Epoch ms embedded in forterToken (`…_{ms}__UDF43…`), or 0 if unknown. */ +export function extractAdobeForterTimestampMs(cookieOrBlob: string): number { + const ftr = + normalizeAdobeForterToken(getAdobeCookieValue(cookieOrBlob, "forterToken")) || + normalizeAdobeForterToken(getAdobeCookieValue(cookieOrBlob, "forter")) || + ""; + const m = ftr.match(/_(\d{13})__/); + return m ? Number(m[1]) : 0; +} + +export function getAdobeForterAgeMs(cookieOrBlob: string): number { + const ts = extractAdobeForterTimestampMs(cookieOrBlob); + if (!ts) return Number.POSITIVE_INFINITY; + return Math.max(0, Date.now() - ts); +} + +/** Remember an ARP that just got generate-async 2xx — batch jobs must stick to it. */ +export function markAdobeFireflyArpSuccess(fingerprint: string, arpSessionId: string): void { + const fp = String(fingerprint || "").trim(); + const arp = String(arpSessionId || "").trim(); + if (!fp || !arp) return; + lastWorkingArpByFingerprint.set(fp, { arp, at: Date.now() }); + consecutiveAdobeSubmitSuccesses += 1; + const cached = sessionCache.get(fp); + if (cached) { + cached.arpSessionId = arp; + cached.updatedAt = Date.now(); + sessionCache.set(fp, cached); + saveDiskSession(cached); + } else { + // Persist sticky ARP even when session map was not primed (fingerprint-only mark). + try { + const path = sessionFilePath(fp); + if (existsSync(path)) { + const obj = JSON.parse(readFileSync(path, "utf8")) as AdobeFireflySession; + obj.arpSessionId = arp; + obj.updatedAt = Date.now(); + writeFileSync(path, JSON.stringify(obj, null, 2), "utf8"); + sessionCache.set(fp, { ...obj, fingerprint: fp }); + } + } catch { + /* best-effort */ + } + } +} + +export function clearAdobeFireflyWorkingArp(fingerprint: string): void { + lastWorkingArpByFingerprint.delete(String(fingerprint || "").trim()); +} + +export function noteAdobeFireflySubmitFailure(): void { + consecutiveAdobeSubmitSuccesses = 0; +} + +/** + * Serialize Firefly generate-async calls and enforce a quiet period. + * Colligo often returns 408 "system under load" when submits are hammered in a batch + * or after a few successes in a row with the same risk session. + */ +export async function withAdobeFireflySubmitGate(fn: () => Promise): Promise { + const run = adobeSubmitChain.then(async () => { + const gap = minSubmitGapMs() + batchExtraGapMs(); + const wait = Math.max(0, lastAdobeSubmitAt + gap - Date.now()); + if (wait > 0) { + await new Promise((r) => setTimeout(r, wait)); + } + try { + return await fn(); + } finally { + lastAdobeSubmitAt = Date.now(); + } + }); + // Keep the chain alive even if fn throws + adobeSubmitChain = run.then( + () => undefined, + () => undefined + ); + return run; +} + +/** + * Rebuild x-arp-session-id from browser cookie components. + * Live successful generate-async ARP is base64(JSON({sid, ark, ftr, bfp?, fpjs?})). + * Returns "" when required pieces are missing. + */ +export function buildAdobeArpSessionIdFromCookies( + cookieOrBlob: string, + extras?: { region?: string; bfp?: string; fpjs?: string } +): string { + const blob = String(cookieOrBlob || ""); + if (!blob.trim()) return ""; + + const sid = + getAdobeCookieValue(blob, "ff_session_guid") || + getAdobeCookieValue(blob, "sid") || + ""; + const ark = getAdobeCookieValue(blob, "arkose") || ""; + const ftr = + normalizeAdobeForterToken(getAdobeCookieValue(blob, "forterToken")) || + normalizeAdobeForterToken(getAdobeCookieValue(blob, "forter")) || + ""; + if (!sid || !ark || !ftr) return ""; + + let bfp = extras?.bfp || getAdobeCookieValue(blob, "bfp") || ""; + let fpjsRaw = extras?.fpjs || getAdobeCookieValue(blob, "fpjs") || ""; + if (fpjsRaw) { + try { + if (/%[0-9A-Fa-f]{2}/.test(fpjsRaw)) fpjsRaw = decodeURIComponent(fpjsRaw); + } catch { + /* keep */ + } + } + + // Prefer rebuilding over a stale sherlockToken when cookie pieces exist — + // forterToken timestamps advance as the SPA warms risk SDKs. + const obj: Record = { sid, ark, ftr }; + if (bfp) obj.bfp = bfp; + if (fpjsRaw) obj.fpjs = fpjsRaw; + return Buffer.from(JSON.stringify(obj), "utf-8").toString("base64"); +} + +/** True when the blob can rebuild a full ARP without a pasted sherlockToken. */ +export function canRebuildAdobeArpFromCookies(cookieOrBlob: string): boolean { + return Boolean(buildAdobeArpSessionIdFromCookies(cookieOrBlob)); +} + +/** + * Resolve the best ARP for a request: + * 1) force-rotate → mint fresh synthetic (or rebuild if cookies present) + * 2) rebuild from cookie pieces (forter/arkose/sid) — usually fresher than sherlock + * 3) explicit sherlockToken / x-arp-session-id from paste + * 4) synthetic rich ARP + */ +export function resolveAdobeArpSessionIdSmart( + cookieOrBlob?: string, + opts?: { rotate?: boolean } +): string { + const blob = String(cookieOrBlob || ""); + if (opts?.rotate) { + const rebuilt = buildAdobeArpSessionIdFromCookies(blob); + if (rebuilt) return rebuilt; + return buildAdobeArpSessionId(); + } + const rebuilt = buildAdobeArpSessionIdFromCookies(blob); + const extracted = extractAdobeArpSessionId(blob); + // Prefer rebuild when both exist: cookie forter is updated by the SPA more often + // than the frozen sherlockToken the user pasted minutes ago. + if (rebuilt && extracted) { + const rebuiltFtr = (() => { + try { + const j = JSON.parse(Buffer.from(rebuilt + "=".repeat((4 - (rebuilt.length % 4)) % 4), "base64").toString("utf8")) as { ftr?: string }; + return String(j.ftr || ""); + } catch { + return ""; + } + })(); + const extractedFtr = (() => { + try { + const j = JSON.parse(Buffer.from(extracted + "=".repeat((4 - (extracted.length % 4)) % 4), "base64").toString("utf8")) as { ftr?: string }; + return String(j.ftr || ""); + } catch { + return ""; + } + })(); + // Prefer the ARP whose forter timestamp is newer (…_ms__UDF43…). + const ts = (ftr: string) => { + const m = ftr.match(/_(\d{13})__/); + return m ? Number(m[1]) : 0; + }; + if (ts(rebuiltFtr) >= ts(extractedFtr)) return rebuilt; + return extracted; + } + if (rebuilt) return rebuilt; + if (extracted) return extracted; + return buildAdobeArpSessionId(); +} + +/** Merge cookie name=value pairs (new wins). Single-line Cookie header. */ +export function mergeAdobeCookieHeaders(base: string, updates: string): string { + const map = new Map(); + const ingest = (raw: string) => { + for (const part of String(raw || "").split(";")) { + const idx = part.indexOf("="); + if (idx <= 0) continue; + let name = part.slice(0, idx).trim(); + let value = part.slice(idx + 1).trim(); + if (!name) continue; + try { + name = decodeURIComponent(name); + } catch { + /* keep */ + } + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (/[\r\n\0]/.test(value)) continue; + map.set(name, value); + } + }; + ingest(extractAdobeCookieHeader(base) || base); + ingest(extractAdobeCookieHeader(updates) || updates); + return [...map.entries()].map(([k, v]) => `${k}=${v}`).join("; "); +} + +/** Serialize session back into a multi-line credential paste (JWT + Cookie). */ +export function serializeAdobeFireflyCredential(session: Pick): string { + const lines: string[] = []; + if (session.accessToken) lines.push(session.accessToken.trim()); + if (session.arpSessionId) lines.push(session.arpSessionId.trim()); + if (session.cookie) lines.push(session.cookie.trim()); + return lines.join("\n"); +} + +export function estimateAdobeTokenExpiry(accessToken: string): number { + const payload = decodeAdobeJwtPayload(accessToken); + if (!payload) return Date.now() + 60 * 60_000; + const created = Number(payload.created_at || 0); + const expiresIn = Number(payload.expires_in || 0); + if (created > 0 && expiresIn > 0) return created + expiresIn; + // Fallback: treat as 20h from now if claims missing + return Date.now() + 20 * 60 * 60_000; +} + +function diskSessionsEnabled(): boolean { + // Unit tests and explicit opt-out skip durable disk cache (avoids sticky IMS skips). + if (process.env.ADOBE_FIREFLY_SESSION_DISK === "0") return false; + if (process.env.NODE_ENV === "test") return false; + if (process.env.VITEST || process.env.NODE_TEST_CONTEXT) return false; + return true; +} + +function loadDiskSession(fingerprint: string): AdobeFireflySession | null { + if (!diskSessionsEnabled()) return null; + try { + const path = sessionFilePath(fingerprint); + if (!existsSync(path)) return null; + const raw = readFileSync(path, "utf8"); + const obj = JSON.parse(raw) as AdobeFireflySession; + if (!obj?.accessToken || !isAdobeUserAccessToken(obj.accessToken)) return null; + return { ...obj, fingerprint, source: "cache" }; + } catch { + return null; + } +} + +function saveDiskSession(session: AdobeFireflySession): void { + if (!diskSessionsEnabled()) return; + try { + const path = sessionFilePath(session.fingerprint); + writeFileSync(path, JSON.stringify(session, null, 2), "utf8"); + } catch { + /* best-effort */ + } +} + +function collectCredentialBlobs( + credentials: AdobeFireflySessionResolveOpts["credentials"] +): string[] { + const out: string[] = []; + const push = (v: unknown) => { + if (typeof v === "string" && v.trim()) out.push(v.trim()); + }; + push(credentials?.apiKey); + push(credentials?.accessToken); + push(credentials?.providerSpecificData?.cookie); + push(credentials?.providerSpecificData?.access_token); + push(credentials?.providerSpecificData?.accessToken); + return out; +} + +/** + * Browser warm for Firefly risk session (Forter/Arkose refresh). + * + * - Default Chrome mode: **off-screen headed** (parked at -32000,-32000). Colligo rejects + * headless Forter tokens; true headless only with ADOBE_FIREFLY_CHROME_HEADLESS=1. + * - Proactive use stays opt-in (ADOBE_FIREFLY_BROWSER_REFRESH=1). + * - Mid-batch 408 recovery may call with force=true so we mint a new ARP without re-paste. + * Never throws — returns null when unavailable. + */ +export async function refreshAdobeSessionViaBrowser( + session: AdobeFireflySession, + log?: AdobeFireflySessionResolveOpts["log"], + opts?: { force?: boolean; proveWithPing?: boolean } +): Promise { + const force = opts?.force === true; + // Browser warm is the default engine now — only the explicit kill switch disables it. + if (!adobeFireflyBrowserEnabled()) return null; + + try { + const { warmAdobeFireflyViaChrome } = await import("./adobeFireflyChromeRuntime.ts"); + const warmed = await warmAdobeFireflyViaChrome({ + cookie: session.cookie, + accessToken: session.accessToken, + log, + waitForLoginMs: 0, // never block on interactive login + // Default engine: warm the off-screen Chrome without requiring BROWSER_REFRESH=1. + allowWithoutEnvOptIn: true, + // Prove colligo accepts the ARP during recovery (in-page generate-async ping). + proveWithPing: opts?.proveWithPing ?? force, + }); + if (!warmed) return null; + + const next: AdobeFireflySession = { + ...session, + accessToken: warmed.accessToken || session.accessToken, + // Prefer warmed jar (fresh forter) over stale paste when keys collide. + cookie: mergeAdobeCookieHeaders(session.cookie, warmed.cookie || ""), + arpSessionId: warmed.arpSessionId, + tokenExpiresAt: warmed.tokenExpiresAt || session.tokenExpiresAt, + updatedAt: Date.now(), + source: "browser", + }; + // When warm returns a fresher forter, prefer its cookie entirely for ARP rebuild pieces. + const warmFtr = extractAdobeForterTimestampMs(warmed.cookie || ""); + const baseFtr = extractAdobeForterTimestampMs(session.cookie || ""); + if (warmFtr > baseFtr && warmed.cookie) { + next.cookie = mergeAdobeCookieHeaders(session.cookie, warmed.cookie); + } + sessionCache.set(session.fingerprint, next); + saveDiskSession(next); + clearAdobeFireflyWorkingArp(session.fingerprint); + log?.info?.( + "ADOBE-FIREFLY", + `off-screen Chrome warm refreshed ARP (len=${next.arpSessionId.length}, force=${force}, forterTs=${warmFtr || 0})` + ); + return next; + } catch (err) { + log?.warn?.( + "ADOBE-FIREFLY", + `browser ARP refresh failed: ${err instanceof Error ? err.message : String(err)}` + ); + return null; + } +} + +/** + * Resolve a durable Firefly session from stored credentials. + * Caches in memory + DATA_DIR; rebuilds ARP from cookies; optionally warms via Playwright. + */ +export async function ensureAdobeFireflySession( + opts: AdobeFireflySessionResolveOpts +): Promise { + const blobs = collectCredentialBlobs(opts.credentials); + if (blobs.length === 0) { + throw new AdobeFireflyError( + "Adobe Firefly credentials missing. Paste the IMS JWT (Authorization: Bearer on firefly-3p) " + + "and ideally the full firefly.adobe.com Cookie (with sherlockToken / forterToken / arkose) once.", + 401, + "missing_credentials" + ); + } + + const joined = blobs.join("\n"); + const fingerprint = fingerprintAdobeCredential(joined); + + // forceRefresh / rotate always drop in-memory cache for this fingerprint + if (opts.forceRefresh) sessionCache.delete(fingerprint); + + const cached = sessionCache.get(fingerprint) || loadDiskSession(fingerprint); + if (cached && !opts.forceRefresh) sessionCache.set(fingerprint, cached); + + const fetchImpl = opts.fetchImpl || fetch; + let accessToken = ""; + let cookie = ""; + let pasteHadUserJwt = false; + + // Prefer JWT from the live paste (authoritative for this request) + for (const b of blobs) { + const tok = extractAdobeCredentialToken(b); + if (looksLikeAdobeJwt(tok) && isAdobeUserAccessToken(tok)) { + accessToken = tok; + pasteHadUserJwt = true; + break; + } + } + // Cookie-only paste: use short-lived memory cache JWT only (not a stale disk token alone) + if ( + !accessToken && + cached?.accessToken && + isAdobeUserAccessToken(cached.accessToken) && + sessionCache.has(fingerprint) && + Date.now() - cached.updatedAt < 30 * 60_000 + ) { + accessToken = cached.accessToken; + } + + // Cookie blob + for (const b of blobs) { + const c = extractAdobeCookieHeader(b); + if (c) { + cookie = c; + break; + } + if (looksLikeAdobeCookieBlob(b)) { + cookie = extractAdobeCookieHeader(b) || b; + break; + } + } + if (!cookie && cached?.cookie) cookie = cached.cookie; + if (cached?.cookie && cookie) cookie = mergeAdobeCookieHeaders(cached.cookie, cookie); + + // Cookie-only or near-expiry JWT → try IMS exchange (needs real IMS cookies on adobelogin.com) + const tokenExpiresAt = accessToken ? estimateAdobeTokenExpiry(accessToken) : 0; + const needJwtRefresh = + !accessToken || + !pasteHadUserJwt || + (tokenExpiresAt > 0 && tokenExpiresAt - Date.now() < JWT_REFRESH_SKEW_MS); + + if (needJwtRefresh && cookie) { + try { + const refreshed = await exchangeAdobeCookieForAccessToken(cookie, fetchImpl); + if (isAdobeUserAccessToken(refreshed)) { + accessToken = refreshed; + opts.log?.info?.("ADOBE-FIREFLY", "IMS cookie exchange produced a user JWT"); + } + } catch { + // Fall through — pure firefly cookies still yield guest-only; keep existing JWT. + } + } + + const cookieBlob = cookie || extractAdobeCookieHeader(joined) || ""; + + if (!accessToken) { + // Try the pure-HTTP resolve (paste JWT / IMS exchange). When the browser engine is on, + // a missing/guest token is NOT fatal here — the off-screen Chrome warm below reads the + // live user JWT from a signed-in profile (the "one-time browser sign-in" path). Only + // surface the guest/missing error when the browser engine is disabled. + try { + accessToken = await resolveAdobeAccessToken(opts.credentials, fetchImpl); + } catch (err) { + if (!adobeFireflyBrowserEnabled()) throw err; + opts.log?.info?.( + "ADOBE-FIREFLY", + "no user JWT from paste/cookie — will read it from the signed-in Chrome profile" + ); + } + } + + const cookieForSession = cookie || cookieBlob; + const forterTs = extractAdobeForterTimestampMs(cookieForSession); + const working = lastWorkingArpByFingerprint.get(fingerprint); + const workingFresh = + working && Date.now() - working.at < WORKING_ARP_STICKY_MS ? working.arp : ""; + + // Prefer last ARP that actually got generate-async 2xx (batch stability). + // Rebuild from cookie pieces / sherlockToken — pure HTTP, no browser. + let arpSessionId = ""; + if (!opts.forceRefresh && !opts.rotateArp && workingFresh) { + arpSessionId = workingFresh; + } else if (!opts.forceRefresh && !opts.rotateArp && cached?.arpSessionId) { + arpSessionId = cached.arpSessionId; + } else { + arpSessionId = resolveAdobeArpSessionIdSmart(cookieForSession || joined, { + rotate: Boolean(opts.rotateArp), + }); + } + + let session: AdobeFireflySession = { + accessToken, + cookie: cookieForSession, + arpSessionId: String(arpSessionId || ""), + tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), + updatedAt: Date.now(), + fingerprint, + source: workingFresh ? "cache" : cached?.source || "paste", + }; + + // Off-screen Chrome Forter-warm is now the DEFAULT engine (kill switch: + // ADOBE_FIREFLY_BROWSER_REFRESH=0). Warm proactively when we lack a usable session so the + // first submit doesn't eat a colligo 408, and so a signed-in profile can supply the user + // JWT with no JWT/cookie paste ("one-time browser sign-in" model): + // - explicit forceRefresh / rotateArp, or + // - no AdobeID user JWT yet (profile may hold one — cookie/JWT-free path), or + // - stale Forter risk session and no recently-accepted (sticky 2xx) ARP to reuse. + const jwtIsUser = isAdobeUserAccessToken(session.accessToken); + const forterAgeMs = getAdobeForterAgeMs(session.cookie); + const riskStale = !workingFresh && forterAgeMs > FORTER_PROACTIVE_WARM_MS; + const shouldWarm = + adobeFireflyBrowserEnabled() && + opts.allowBrowserRefresh !== false && + (opts.forceRefresh || opts.rotateArp || !jwtIsUser || riskStale); + // Need something to warm from: a cookie to seed, a signed-in profile (no user JWT yet), + // or an explicit refresh request. + const canWarm = Boolean(session.cookie) || !jwtIsUser || Boolean(opts.forceRefresh); + if (shouldWarm && canWarm) { + const key = fingerprint; + let inflight = browserRefreshInFlight.get(key); + if (!inflight) { + inflight = refreshAdobeSessionViaBrowser(session, opts.log, { + force: true, + proveWithPing: Boolean(opts.forceRefresh), + }).finally(() => { + browserRefreshInFlight.delete(key); + }); + browserRefreshInFlight.set(key, inflight); + } + const warmed = await inflight; + if (warmed) { + session = { ...warmed, fingerprint }; + opts.log?.info?.( + "ADOBE-FIREFLY", + `off-screen Chrome session warm applied (reason=${opts.forceRefresh ? "force" : opts.rotateArp ? "rotate" : !jwtIsUser ? "no-user-jwt" : "stale-forter"})` + ); + } + } + + // Final ARP if still empty + if (!session.arpSessionId) { + session.arpSessionId = resolveAdobeArpSessionIdSmart(session.cookie || joined); + } + // Re-apply sticky working ARP if warm did not produce a newer forter-based ARP + if (workingFresh && !opts.forceRefresh && !opts.rotateArp) { + const warmForterTs = extractAdobeForterTimestampMs(session.cookie); + if (!(warmForterTs > forterTs)) { + session.arpSessionId = workingFresh; + session.source = "cache"; + } + } + + // No usable AdobeID user JWT after the warm → marker-only credentials or cold profile. + if (!isAdobeUserAccessToken(session.accessToken)) { + throw new AdobeFireflyError( + "Adobe Firefly is not signed in. On Providers → Adobe Firefly → Add Account (OAuth) choose " + + "\"Sign in with browser\" (fresh login window) or \"Paste JWT / Cookie\". After browser sign-in " + + "the app stores JWT+Cookie and keeps the risk session fresh automatically.", + 401, + "not_signed_in" + ); + } + + sessionCache.set(fingerprint, session); + saveDiskSession(session); + return session; +} + +/** + * After a colligo 408: clear sticky ARP, try browser warm for a NEW forter, fall back carefully. + * Rebuilding from the same forter cookie is a no-op and must not burn all retries. + * + * Policy: + * - Fresh forter + attempt 1–2 → quiet reuse (rate-limit masquerading as 408). + * - Stale forter (age > FORTER_STALE_MS) OR attempt ≥ 3 → off-screen Chrome warm immediately. + */ +export async function rotateAdobeFireflySessionOnError( + session: AdobeFireflySession, + opts?: { + tryBrowser?: boolean; + log?: AdobeFireflySessionResolveOpts["log"]; + /** Attempt index (1-based) for backoff policy. */ + attempt?: number; + } +): Promise { + const prevArp = session.arpSessionId; + const attempt = opts?.attempt ?? 1; + const forterTs = extractAdobeForterTimestampMs(session.cookie); + const forterAgeMs = forterTs > 0 ? Math.max(0, Date.now() - forterTs) : null; + // Only treat as "known stale" when the cookie embeds a forter timestamp we can age. + // Unknown age (synthetic ARP / tests) keeps the quiet 1–2 reuse path. + const forterKnownStale = forterAgeMs != null && forterAgeMs > FORTER_STALE_MS; + + // Attempt 1–2 when forter is not known-stale: keep same ARP (colligo short load / rate limit). + // Hours-old forter → skip quiet reuse and warm Chrome immediately (else all 5 attempts 408). + if (attempt <= 2 && !forterKnownStale) { + const same: AdobeFireflySession = { ...session, updatedAt: Date.now(), source: "cache" }; + sessionCache.set(session.fingerprint, same); + saveDiskSession(same); + opts?.log?.info?.( + "ADOBE-FIREFLY", + `408 recovery: reusing ARP (quiet period, attempt ${attempt}, forterAgeMs=${forterAgeMs ?? "unknown"})` + ); + return same; + } + + // Known-stale forter or attempt 3+: cookie rebuild is a no-op. Off-screen headed Chrome mints a + // fresh Forter/ARP (headless is rejected by colligo — see adobeFireflyChromeRuntime). + clearAdobeFireflyWorkingArp(session.fingerprint); + noteAdobeFireflySubmitFailure(); + + const tryBrowser = + opts?.tryBrowser !== false && process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0"; + if (tryBrowser && session.cookie) { + opts?.log?.info?.( + "ADOBE-FIREFLY", + `408 recovery: off-screen Chrome warm (attempt=${attempt}, forterKnownStale=${forterKnownStale}, forterAgeMs=${forterAgeMs ?? "unknown"})` + ); + const warmed = await refreshAdobeSessionViaBrowser(session, opts?.log, { + force: true, + proveWithPing: true, + }); + if (warmed?.arpSessionId) { + const next = { ...warmed, fingerprint: session.fingerprint }; + sessionCache.set(session.fingerprint, next); + saveDiskSession(next); + opts?.log?.info?.( + "ADOBE-FIREFLY", + `408 recovery: Chrome warm done (arp changed=${warmed.arpSessionId !== prevArp}, forterTs=${extractAdobeForterTimestampMs(warmed.cookie)})` + ); + return next; + } + } + + const rebuilt = resolveAdobeArpSessionIdSmart(session.cookie, { rotate: true }); + const next: AdobeFireflySession = { + ...session, + arpSessionId: rebuilt && rebuilt !== prevArp ? rebuilt : session.arpSessionId, + updatedAt: Date.now(), + source: "rebuild", + }; + sessionCache.set(session.fingerprint, next); + saveDiskSession(next); + return next; +} + +/** Test helper — clear in-memory session cache. */ +export function __resetAdobeFireflySessionCacheForTests(): void { + sessionCache.clear(); + browserRefreshInFlight.clear(); + lastWorkingArpByFingerprint.clear(); + lastAdobeSubmitAt = 0; + consecutiveAdobeSubmitSuccesses = 0; + adobeSubmitChain = Promise.resolve(); +} diff --git a/src/app/api/providers/[id]/login/route.ts b/src/app/api/providers/[id]/login/route.ts index 9a83cb8765..947329a836 100644 --- a/src/app/api/providers/[id]/login/route.ts +++ b/src/app/api/providers/[id]/login/route.ts @@ -39,6 +39,85 @@ export async function POST( const timeout = typeof body.timeout === "number" ? body.timeout : undefined; const providerSlug = resolveProviderSlug(provider as Record); + // Adobe Firefly uses a persistent off-screen Chrome profile (Forter/Arkose need a real + // browser). Sign in once in a visible window; the profile then keeps the session fresh + // with no token/cookie to paste. This is NOT the generic cookie-scrape login path. + if (String(provider.provider || "") === "adobe-firefly") { + try { + const { loginAdobeFireflyViaChrome } = await import( + "@omniroute/open-sse/services/adobeFireflyChromeRuntime.ts" + ); + const cookieHint = typeof provider.api_key === "string" ? provider.api_key : undefined; + // Default freshSession=true so "Add Account" can sign into a different Adobe identity + // instead of silently reusing the previous SSO in the managed Chrome profile. + const freshSession = + typeof (body as { freshSession?: unknown }).freshSession === "boolean" + ? Boolean((body as { freshSession?: boolean }).freshSession) + : true; + const result = await loginAdobeFireflyViaChrome({ + cookie: cookieHint, + waitForLoginMs: timeout, + freshSession, + }); + if (result.success) { + // Persist JWT + Cookie (multi-line) so generate works immediately. sessionStorage JWT + // dies when Chrome closes; the cookie jar + IMS SSO in the profile cover refresh/warm. + const accessToken = String(result.accessToken || "").trim(); + const cookie = String(result.cookie || "").trim(); + const credential = + accessToken && cookie + ? `${accessToken}\n${cookie}` + : accessToken || cookie || JSON.stringify({ + mode: "browser-profile", + account: result.account || "", + signedInAt: Date.now(), + }); + const marker = { + mode: "browser-profile", + account: result.account || "", + signedInAt: Date.now(), + arpSessionId: result.arpSessionId || "", + }; + try { + await updateProviderConnection(id, { + api_key: credential, + provider_specific_data: { + ...marker, + cookie: credential, + access_token: accessToken || undefined, + }, + }); + } catch { + /* non-fatal — return credentials to the host app either way */ + } + return NextResponse.json({ + success: true, + account: result.account, + accessToken: accessToken || undefined, + cookie: cookie || undefined, + arpSessionId: result.arpSessionId || undefined, + credential, + persisted: true, + }); + } + return NextResponse.json( + { + success: false, + error: + "Sign-in did not complete. Open the browser window that appeared, log into your Adobe " + + "account, and keep it open until this finishes.", + }, + { status: 400 } + ); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err); + return NextResponse.json( + { success: false, error: `Adobe Firefly sign-in error: ${msg}` }, + { status: 500 } + ); + } + } + try { // Adobe Firefly is special: the IMS JWT is only ever in the Authorization // header of firefly-3p.ff.adobe.io XHRs (never cookies/localStorage), so diff --git a/tests/unit/adobe-firefly.test.ts b/tests/unit/adobe-firefly.test.ts index f7dff8c25d..f29e9a1422 100644 --- a/tests/unit/adobe-firefly.test.ts +++ b/tests/unit/adobe-firefly.test.ts @@ -918,6 +918,11 @@ test("resolveAdobeImageModel maps gpt-image-2 alias", async () => { }); test("image submit retries on 408 then succeeds", async () => { + const { __resetAdobeFireflySessionCacheForTests } = await import( + "../../open-sse/services/adobeFireflySession.ts" + ); + __resetAdobeFireflySessionCacheForTests(); + let submits = 0; const userTok = userImsJwt(); const fetchImpl = async (url: string) => { @@ -952,6 +957,91 @@ test("image submit retries on 408 then succeeds", async () => { assert.match(result.url, /retry\.png/); }); +test("sticky ARP: successful submit is reused by ensure on next call", async () => { + const { + __resetAdobeFireflySessionCacheForTests, + markAdobeFireflyArpSuccess, + ensureAdobeFireflySession, + fingerprintAdobeCredential, + } = await import("../../open-sse/services/adobeFireflySession.ts"); + const { ADOBE_FIREFLY_FTR_MAGIC } = await import("../../open-sse/services/adobeFireflyClient.ts"); + __resetAdobeFireflySessionCacheForTests(); + + const userTok = userImsJwt(); + const ftr = `aab9dc9eb48f4ee1916428649f908f7d_${Date.now()}${ADOBE_FIREFLY_FTR_MAGIC}_x=-1-v2_tt`; + const ark = + "87818c58b11662a57.5347274705|r=eu-west-1|meta=3|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C|at=40"; + const cookie = + `ff_session_guid=bdf37b8a-117f-467d-a737-7792932d98b4; arkose=${ark}; ` + + `forterToken=${encodeURIComponent(ftr)}`; + const cred = `${userTok}\n${cookie}`; + const fp = fingerprintAdobeCredential(cred); + const stickyArp = Buffer.from( + JSON.stringify({ sid: "sticky-sid", ark: "sticky-ark", ftr: "sticky-ftr" }), + "utf8" + ).toString("base64"); + + markAdobeFireflyArpSuccess(fp, stickyArp); + + const session = await ensureAdobeFireflySession({ + credentials: { apiKey: cred }, + allowBrowserRefresh: false, + fetchImpl: (async () => { + throw new Error("no network expected"); + }) as typeof fetch, + }); + assert.equal(session.arpSessionId, stickyArp, "ensure must stick to last successful ARP"); + assert.equal(session.fingerprint, fp); +}); + +test("rotateAdobeFireflySessionOnError: attempt1-2 reuse sticky; attempt3 keeps ARP without browser", async () => { + const { + __resetAdobeFireflySessionCacheForTests, + rotateAdobeFireflySessionOnError, + markAdobeFireflyArpSuccess, + buildAdobeArpSessionIdFromCookies, + } = await import("../../open-sse/services/adobeFireflySession.ts"); + const { ADOBE_FIREFLY_FTR_MAGIC } = await import("../../open-sse/services/adobeFireflyClient.ts"); + __resetAdobeFireflySessionCacheForTests(); + + const ftr = `aa_${Date.now()}${ADOBE_FIREFLY_FTR_MAGIC}_x=-1-v2_tt`; + const cookie = + `ff_session_guid=sid-1; arkose=ark-1; forterToken=${encodeURIComponent(ftr)}`; + const rebuilt = buildAdobeArpSessionIdFromCookies(cookie); + assert.ok(rebuilt); + + const base = { + accessToken: userImsJwt(), + cookie, + arpSessionId: rebuilt!, + tokenExpiresAt: Date.now() + 3600_000, + updatedAt: Date.now(), + fingerprint: "fp-rotate-test", + source: "paste" as const, + }; + markAdobeFireflyArpSuccess(base.fingerprint, rebuilt!); + + const a1 = await rotateAdobeFireflySessionOnError(base, { + attempt: 1, + tryBrowser: false, + }); + assert.equal(a1.arpSessionId, rebuilt, "attempt 1 reuses ARP (rate-limit quiet)"); + + const a2 = await rotateAdobeFireflySessionOnError( + { ...base, arpSessionId: rebuilt! }, + { attempt: 2, tryBrowser: false } + ); + assert.equal(a2.arpSessionId, rebuilt, "attempt 2 still reuses ARP (mid-batch quiet)"); + + const a3 = await rotateAdobeFireflySessionOnError( + { ...base, arpSessionId: rebuilt! }, + { attempt: 3, tryBrowser: false } + ); + // attempt 3 without browser: cookie rebuild (identical forter → same ARP) + assert.ok(a3.arpSessionId, "attempt 3 still yields an ARP"); + assert.equal(a3.arpSessionId, rebuilt, "identical cookie rebuild keeps ARP (no synthetic thrash)"); +}); + test("adobeFireflyGenerateImage cookie path exchanges IMS token first", async () => { const { __resetAdobeFireflySessionCacheForTests } = await import( "../../open-sse/services/adobeFireflySession.ts"