Files
OmniRoute/open-sse/services/adobeFireflySession.ts
Diego Rodrigues de Sa e Souza 57fb90d734 maint: follow-up cherry-pick fix-in-place #9549 (conflict-resolved fallback) (#9881)
* fix(adobe-firefly): open browser sign-in and resolve provider slug in /login

POST /api/providers/[id]/login passed the connection DB id to
inAppLoginService.startLogin, but that service looks up the provider by
slug in TOKEN_EXTRACTION_CONFIGS. The lookup always missed and returned
"No extraction config" without launching a browser — so the VibeProxy
"Sign in" button for Adobe Firefly (and every other web-cookie provider)
never opened a browser.

Adobe Firefly additionally had no extraction config because its IMS JWT
is never in cookies/localStorage — it only rides on the Authorization:
Bearer header of firefly-3p.ff.adobe.io XHRs.

- Resolve the provider slug from the connection row and pass the slug
  (not the DB id) to inAppLoginService.startLogin.
- Add open-sse/services/adobeFireflyBrowserLogin.ts: a Playwright
  service that launches a visible browser at firefly.adobe.com and
  intercepts firefly-3p requests to capture the IMS JWT + sherlockToken
  cookie. Wire it into the /login route for the adobe-firefly slug.
- Fix latent bug: updateProviderConnection reads camelCase keys
  (apiKey, providerSpecificData), so the previous snake_case call never
  persisted extracted credentials.

* fix(adobe-firefly): open browser sign-in and resolve provider slug in /login

POST /api/providers/[id]/login passed the connection DB id to
inAppLoginService.startLogin, but TOKEN_EXTRACTION_CONFIGS is keyed by
provider slug — so browser login never launched for web-cookie providers.

Adobe Firefly also cannot use cookie extraction: the IMS JWT only appears
on Authorization headers to firefly-3p.ff.adobe.io. Add a dedicated
Playwright interceptor and persist credentials with camelCase keys that
updateProviderConnection actually reads.

* fix(adobe-firefly): use system Chrome/Edge CDP for browser sign-in

Playwright is not available inside the pkg-packaged VibeProxyServices.exe,
so import('playwright') always failed with 'Playwright not installed' and
never opened a window. Launch Chrome/Edge with --remote-debugging-port and
capture the firefly-3p Authorization Bearer via pure CDP WebSocket instead.

* fix(adobe-firefly): live x-arp-session-id / Arkose wire (stop 408 under load)

Browser generate-async requires x-arp-session-id as base64({sid,ark,ftr}) with a
real Arkose blob (sherlockToken). JWT alone frequently returns colligo HTTP 408
system under load while credits still work.

- Match live ftr magic __UDF43-m4_31ck + Arkose pk in synthetic ARP fallback
- Ranked extract of sherlockToken / x-arp from Cookie, HAR, fetch() paste, and
  space-joined JWT+ARP (PasswordBox newline collapse)
- Reuse one ARP for storage upload + generate-async
- Clearer 408 errors when browser ARP is missing vs stale
- Unit suite 42/42

* fix(adobe-firefly): durable session ARP rebuild and aux_sid false-positive

Rebuild x-arp-session-id from forterToken/arkose/ff_session_guid instead of
ranking long Cookie pairs (e.g. aux_sid=…) as opaque ARP, which caused colligo
HTTP 408. Cache IMS JWT + cookie sessions, rotate ARP on 408 retries, and keep
Playwright warm-up opt-in only (headless Forter is rejected).

Also expand synthetic ARP shape with bfp/fpjs to match live successful captures.

* 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.

* fix(adobe-firefly): renew sessions through durable CDP

* fix(adobe-firefly): isolate browser sessions per account

* fix(adobe-firefly): make account login fresh and deterministic

* chore(adobe-firefly): remove obsolete browser fallback

* docs(adobe-firefly): document renewal controls

* fix(adobe-firefly): harden CDP warm, risk session, and browser sign-in

Stop colligo 408 thrash from stale Forter and frozen Google login during
Sign in with browser:

- CDP warm: clear Firefly origin storage + risk cookies (keep SSO); require
  forter age under 10 minutes on loop and timeout paths; dual CDP queues;
  await Runtime.runIfWaitingForDebugger; profile-lock launch retries
- Session: connectionId fingerprint; write-back JWT+Cookie; warm-fail
  cooldown; fail closed risk_session_stale when forter is known-stale
- Client: submit gate around generate-async; max 2 attempts when forter
  known-stale; poll 401 one refresh; pass sessionBrowserKey through handlers
- Login route: pure system Chrome/Edge CDP only; camelCase credential persist
- Unit: browser-login + firefly suites green (60)

---------

Co-authored-by: artickc <artur1992123@mail.ru>
2026-08-09 10:07:17 -03:00

1003 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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) Packaged-safe Chrome/CDP warm on stale risk state, JWT expiry, or 408 recovery.
* The durable browser profile holds Adobe SSO; Playwright is not required.
*/
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;
/** Stable provider connection id used to isolate browser SSO/cookie state per Adobe account. */
browserSessionKey?: string;
source: "paste" | "ims" | "browser" | "cache" | "rebuild";
}
export interface AdobeFireflySessionResolveOpts {
credentials?: {
apiKey?: string;
accessToken?: string;
connectionId?: string;
providerSpecificData?: {
cookie?: unknown;
access_token?: unknown;
accessToken?: unknown;
browserSessionKey?: 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 durable CDP refresh (tests / hosts without Chrome or Edge). */
allowBrowserRefresh?: boolean;
}
const sessionCache = new Map<string, AdobeFireflySession>();
const browserRefreshInFlight = new Map<string, Promise<AdobeFireflySession | null>>();
/** Last ARP that produced HTTP 2xx on generate-async — prefer until colligo 408. */
const lastWorkingArpByFingerprint = new Map<string, { arp: string; at: number }>();
/** After a failed force-warm, skip re-launching Chrome for this fingerprint for a short window. */
const browserWarmFailureCooldown = new Map<string, number>();
const BROWSER_WARM_FAIL_COOLDOWN_MS = 90_000;
/** Serialize Firefly generate submits + enforce a quiet period (colligo rate-limits look like 408). */
let adobeSubmitChain: Promise<void> = 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<T>(fn: () => Promise<T>): Promise<T> {
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<string, string> = { 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<string, string>();
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<AdobeFireflySession, "accessToken" | "cookie" | "arpSessionId">
): 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 + IMS JWT refresh).
* Uses the same persistent pure-CDP profile as interactive sign-in, including in pkg builds.
* Never throws — returns null when unavailable.
*/
/**
* Best-effort write refreshed JWT+Cookie back to provider_connections so restarts
* and WinUI sync do not keep serving a guest/stale paste after a successful warm.
*/
async function writeBackAdobeFireflyCredentials(
session: AdobeFireflySession,
log?: AdobeFireflySessionResolveOpts["log"]
): Promise<void> {
const connectionId = String(session.browserSessionKey || "").trim();
if (!connectionId || connectionId === "legacy-default") return;
if (!isAdobeUserAccessToken(session.accessToken)) return;
// Skip when connectionId looks like a credential fingerprint (32 hex) without a real UUID.
// Real OmniRoute connection ids are UUIDs; still attempt write-back for any non-empty key.
try {
const { updateProviderConnection } = await import("@/lib/db/providers");
const credential = serializeAdobeFireflyCredential(session);
await updateProviderConnection(connectionId, {
apiKey: credential,
providerSpecificData: {
mode: "browser-profile",
adobeFireflyMode: "browser-profile",
cookie: session.cookie || credential,
access_token: session.accessToken,
browserSessionKey: connectionId,
arpSessionId: session.arpSessionId || "",
refreshedAt: Date.now(),
},
});
log?.info?.(
"ADOBE-FIREFLY",
`wrote refreshed JWT+Cookie to connection ${connectionId.slice(0, 8)}`
);
} catch (err) {
log?.warn?.(
"ADOBE-FIREFLY",
`credential write-back skipped: ${err instanceof Error ? err.message : String(err)}`
);
}
}
export async function refreshAdobeSessionViaBrowser(
session: AdobeFireflySession,
log?: AdobeFireflySessionResolveOpts["log"],
opts?: { force?: boolean; proveWithPing?: boolean }
): Promise<AdobeFireflySession | null> {
const force = opts?.force === true;
// Browser warm is the default engine now — only the explicit kill switch disables it.
if (!adobeFireflyBrowserEnabled()) return null;
const coolKey = String(session.browserSessionKey || session.fingerprint || "").trim();
const coolUntil = coolKey ? browserWarmFailureCooldown.get(coolKey) || 0 : 0;
if (force && coolUntil > Date.now()) {
log?.warn?.(
"ADOBE-FIREFLY",
`skip CDP warm (cooldown ${Math.ceil((coolUntil - Date.now()) / 1000)}s after recent failure)`
);
return null;
}
try {
const baseFtr = extractAdobeForterTimestampMs(session.cookie || "");
const { refreshAdobeFireflyViaCdp } = await import("./adobeFireflyBrowserLogin.ts");
const warmed = await refreshAdobeFireflyViaCdp({
cookie: session.cookie,
accessToken: session.accessToken,
log,
timeoutMs: force ? 90_000 : 75_000,
sessionKey: session.browserSessionKey || session.fingerprint,
});
if (!warmed) {
if (force && coolKey) {
browserWarmFailureCooldown.set(coolKey, Date.now() + BROWSER_WARM_FAIL_COOLDOWN_MS);
}
return null;
}
if (coolKey) browserWarmFailureCooldown.delete(coolKey);
// Prefer warm cookie as authority for risk pieces (do not re-merge stale forter over new).
// On force warm, prefer the warmed cookie as authority (do not re-merge hours-old forter
// from the previous session blob over a freshly minted jar).
const nextCookie = force
? warmed.cookie || session.cookie
: warmed.cookie
? mergeAdobeCookieHeaders(session.cookie || "", warmed.cookie)
: session.cookie;
const warmFtr = extractAdobeForterTimestampMs(nextCookie);
const warmAge = warmFtr > 0 ? Math.max(0, Date.now() - warmFtr) : Number.POSITIVE_INFINITY;
// Force path: require a parseable forter younger than FORTER_STALE (or strictly newer than base).
if (force) {
const advanced =
warmFtr > 0 && (baseFtr <= 0 || warmFtr > baseFtr || warmAge < FORTER_STALE_MS);
if (!advanced) {
log?.warn?.(
"ADOBE-FIREFLY",
`CDP warm rejected: forter not advanced (base=${baseFtr}, warm=${warmFtr || 0}, ageMs=${Number.isFinite(warmAge) ? warmAge : "inf"})`
);
return null;
}
}
const nextArp =
warmed.arpSessionId ||
buildAdobeArpSessionIdFromCookies(nextCookie) ||
extractAdobeArpSessionId(nextCookie);
if (!nextArp) return null;
const nextToken =
(warmed.accessToken && isAdobeUserAccessToken(warmed.accessToken)
? warmed.accessToken
: "") || session.accessToken;
if (!isAdobeUserAccessToken(nextToken)) return null;
const next: AdobeFireflySession = {
...session,
accessToken: nextToken,
cookie: nextCookie,
arpSessionId: nextArp,
tokenExpiresAt: estimateAdobeTokenExpiry(nextToken),
updatedAt: Date.now(),
browserSessionKey: session.browserSessionKey || session.fingerprint,
source: "browser",
};
sessionCache.set(session.fingerprint, next);
saveDiskSession(next);
clearAdobeFireflyWorkingArp(session.fingerprint);
void writeBackAdobeFireflyCredentials(next, log);
log?.info?.(
"ADOBE-FIREFLY",
`durable CDP warm refreshed session (arpLen=${next.arpSessionId.length}, force=${force}, forterTs=${warmFtr || 0}, forterDeltaMs=${warmFtr && baseFtr ? warmFtr - baseFtr : 0})`
);
return next;
} catch (err) {
if (force && coolKey) {
browserWarmFailureCooldown.set(coolKey, Date.now() + BROWSER_WARM_FAIL_COOLDOWN_MS);
}
log?.warn?.(
"ADOBE-FIREFLY",
`browser CDP session 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 durable CDP.
*/
export async function ensureAdobeFireflySession(
opts: AdobeFireflySessionResolveOpts
): Promise<AdobeFireflySession> {
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");
// Prefer stable connection-scoped fingerprint so JWT/cookie refresh does not orphan
// the session cache / sticky ARP map (paste hash changes every warm write-back).
const connectionId = String(
opts.credentials?.connectionId ||
opts.credentials?.providerSpecificData?.browserSessionKey ||
""
).trim();
const fingerprint = connectionId
? fingerprintAdobeCredential(`conn:${connectionId}`)
: fingerprintAdobeCredential(joined);
const browserSessionKey = connectionId || fingerprint;
// forceRefresh / rotate always drop in-memory cache for this fingerprint
if (opts.forceRefresh) sessionCache.delete(fingerprint);
// Also try legacy paste-hash session files (pre-connection-scoped fingerprints).
const legacyFingerprint = fingerprintAdobeCredential(joined);
const cached =
sessionCache.get(fingerprint) ||
loadDiskSession(fingerprint) ||
(legacyFingerprint !== fingerprint ? loadDiskSession(legacyFingerprint) : null);
if (cached && !opts.forceRefresh) {
// Re-key legacy disk session under the stable connection fingerprint.
const normalized = {
...cached,
fingerprint,
browserSessionKey: cached.browserSessionKey || browserSessionKey,
};
sessionCache.set(fingerprint, normalized);
}
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;
}
}
// A browser-refreshed disk token must survive process restarts. Prefer it when the pasted
// token is absent or near expiry; the fingerprint still binds it to these credentials.
const pastedExpiresAt = accessToken ? estimateAdobeTokenExpiry(accessToken) : 0;
const cachedExpiresAt = cached?.accessToken
? cached.tokenExpiresAt > 0
? cached.tokenExpiresAt
: estimateAdobeTokenExpiry(cached.accessToken)
: 0;
if (
cached?.accessToken &&
isAdobeUserAccessToken(cached.accessToken) &&
cachedExpiresAt - Date.now() >= JWT_REFRESH_SKEW_MS &&
(!accessToken || pastedExpiresAt - Date.now() < JWT_REFRESH_SKEW_MS)
) {
accessToken = cached.accessToken;
pasteHadUserJwt = false;
}
// 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 || cached?.accessToken || ""),
updatedAt: Date.now(),
fingerprint,
browserSessionKey,
source: workingFresh ? "cache" : cached?.source || "paste",
};
// Prefer connection-scoped browser profile always (never empty → legacy-default).
if (!session.browserSessionKey) session.browserSessionKey = browserSessionKey;
// 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 jwtNeedsBrowserRefresh =
!jwtIsUser || session.tokenExpiresAt - Date.now() < JWT_REFRESH_SKEW_MS;
const forterAgeMs = getAdobeForterAgeMs(session.cookie);
const riskStale = !workingFresh && forterAgeMs > FORTER_PROACTIVE_WARM_MS;
const shouldWarm =
adobeFireflyBrowserEnabled() &&
opts.allowBrowserRefresh !== false &&
(opts.forceRefresh || opts.rotateArp || jwtNeedsBrowserRefresh || riskStale);
// A persistent signed-in browser profile can refresh even when the stored cookie is empty.
const canWarm = true;
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",
`durable CDP session warm applied (reason=${opts.forceRefresh ? "force" : opts.rotateArp ? "rotate" : jwtNeedsBrowserRefresh ? "jwt-expiry" : "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"
);
}
if (session.tokenExpiresAt <= Date.now() + 30_000) {
throw new AdobeFireflyError(
"Adobe Firefly browser session expired and could not renew automatically. Re-open the " +
"Adobe Firefly account and sign in once so the durable browser profile can renew future JWTs.",
401,
"session_expired"
);
}
// Dead Forter risk session: colligo returns 408 for ~minutes/hours of retries. Fail closed
// with a re-login instruction instead of burning ~600s of generate-async attempts.
// Only when forter timestamp is parseable and old — missing timestamp is not treated as stale
// (JWT-only / synthetic ARP / unit fixtures).
const finalForterTs = extractAdobeForterTimestampMs(session.cookie);
const finalForterAge = getAdobeForterAgeMs(session.cookie);
const hasStickyWorking =
Boolean(workingFresh) &&
Date.now() - (lastWorkingArpByFingerprint.get(fingerprint)?.at || 0) < WORKING_ARP_STICKY_MS;
if (
finalForterTs > 0 &&
Number.isFinite(finalForterAge) &&
finalForterAge > FORTER_STALE_MS &&
!hasStickyWorking &&
opts.allowBrowserRefresh !== false
) {
throw new AdobeFireflyError(
"Adobe Firefly risk session expired (Forter/Arkose). Open Providers → Adobe Firefly → " +
"Add Account (OAuth) → Sign in with browser once. After sign-in the app stores a fresh " +
"JWT+Cookie and refreshes them automatically for later generates.",
401,
"risk_session_stale"
);
}
session.fingerprint = fingerprint;
session.browserSessionKey = session.browserSessionKey || browserSessionKey;
sessionCache.set(fingerprint, session);
saveDiskSession(session);
// Keep SQLite in sync when we have a real connection + user JWT (best-effort).
if (session.source === "browser" || session.source === "rebuild") {
void writeBackAdobeFireflyCredentials(session, opts.log);
}
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 12 → 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;
/** 401/403: bypass quiet ARP reuse and refresh JWT + cookies immediately. */
authFailure?: boolean;
}
): Promise<AdobeFireflySession> {
if (session.tokenExpiresAt <= 0) {
session = {
...session,
tokenExpiresAt: estimateAdobeTokenExpiry(session.accessToken),
};
}
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 12 reuse path.
const forterKnownStale = forterAgeMs != null && forterAgeMs > FORTER_STALE_MS;
// Attempt 12 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 && !opts?.authFailure) {
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. CDP warm mints a fresh
// Forter/ARP via offscreen headed Chrome by default (colligo rejects true headless).
// ADOBE_FIREFLY_CHROME_HEADLESS=1 is debug-only and usually keeps returning 408.
clearAdobeFireflyWorkingArp(session.fingerprint);
noteAdobeFireflySubmitFailure();
const tryBrowser =
opts?.tryBrowser !== false && process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0";
if (tryBrowser) {
opts?.log?.info?.(
"ADOBE-FIREFLY",
`${opts?.authFailure ? "auth" : "408"} recovery: durable CDP 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",
`${opts?.authFailure ? "auth" : "408"} recovery: CDP 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();
browserWarmFailureCooldown.clear();
lastAdobeSubmitAt = 0;
consecutiveAdobeSubmitSuccesses = 0;
adobeSubmitChain = Promise.resolve();
}