mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 21:52:21 +03:00
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)
This commit is contained in:
@@ -51,7 +51,17 @@ export async function handleAdobeFireflyImageGeneration({
|
||||
images?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
credentials: { apiKey?: string; accessToken?: string };
|
||||
credentials: {
|
||||
apiKey?: string;
|
||||
accessToken?: string;
|
||||
connectionId?: string;
|
||||
providerSpecificData?: {
|
||||
cookie?: unknown;
|
||||
access_token?: unknown;
|
||||
accessToken?: unknown;
|
||||
browserSessionKey?: unknown;
|
||||
} | null;
|
||||
};
|
||||
log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void };
|
||||
fetchImpl?: typeof fetch;
|
||||
}) {
|
||||
@@ -88,10 +98,7 @@ export async function handleAdobeFireflyImageGeneration({
|
||||
|
||||
// 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 maxRefs = resolvedId.includes("nano-banana") || resolvedId.includes("gpt-image") ? 4 : 2;
|
||||
|
||||
const sourceImageIds = await resolveAdobeSourceImageIds({
|
||||
accessToken,
|
||||
@@ -119,12 +126,12 @@ export async function handleAdobeFireflyImageGeneration({
|
||||
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,
|
||||
negativePrompt: typeof body.negative_prompt === "string" ? body.negative_prompt : undefined,
|
||||
sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined,
|
||||
sessionCookie,
|
||||
arpSessionId,
|
||||
sessionFingerprint: session.fingerprint,
|
||||
sessionBrowserKey: session.browserSessionKey,
|
||||
timeoutMs,
|
||||
fetchImpl,
|
||||
log,
|
||||
|
||||
@@ -31,7 +31,17 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
provider: string;
|
||||
providerConfig?: { baseUrl?: string };
|
||||
body: Record<string, unknown>;
|
||||
credentials?: { apiKey?: string; accessToken?: string } | null;
|
||||
credentials?: {
|
||||
apiKey?: string;
|
||||
accessToken?: string;
|
||||
connectionId?: string;
|
||||
providerSpecificData?: {
|
||||
cookie?: unknown;
|
||||
access_token?: unknown;
|
||||
accessToken?: unknown;
|
||||
browserSessionKey?: unknown;
|
||||
} | null;
|
||||
} | null;
|
||||
log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void };
|
||||
fetchImpl?: typeof fetch;
|
||||
}) {
|
||||
@@ -104,6 +114,7 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
sessionCookie,
|
||||
arpSessionId,
|
||||
sessionFingerprint: session.fingerprint,
|
||||
sessionBrowserKey: session.browserSessionKey,
|
||||
timeoutMs,
|
||||
fetchImpl,
|
||||
log,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1200
open-sse/services/adobeFireflyChromeRuntime.ts
Normal file
1200
open-sse/services/adobeFireflyChromeRuntime.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1751,7 +1751,7 @@ export function formatAdobeSystemUnderLoadError(
|
||||
`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). ` +
|
||||
`and on 408 auto-warms Forter/ARP via off-screen Chrome CDP (true headless is rejected by colligo — set ADOBE_FIREFLY_CHROME_HEADLESS=1 only for debug). ` +
|
||||
`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.`
|
||||
);
|
||||
@@ -2322,6 +2322,9 @@ async function pollAdobeJob(opts: {
|
||||
kind: "image" | "video";
|
||||
timeoutMs: number;
|
||||
pollIntervalMs?: number;
|
||||
/** Optional session cookie so a mid-poll 401 can renew JWT once via CDP. */
|
||||
sessionCookie?: string;
|
||||
sessionFingerprint?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
log?: {
|
||||
info?: (...args: unknown[]) => void;
|
||||
@@ -2334,12 +2337,14 @@ async function pollAdobeJob(opts: {
|
||||
opts.pollIntervalMs && opts.pollIntervalMs > 0 ? opts.pollIntervalMs : DEFAULT_POLL_INTERVAL_MS;
|
||||
let attempt = 0;
|
||||
let latest: unknown = {};
|
||||
let accessToken = opts.accessToken;
|
||||
let authRefreshAttempted = false;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
attempt += 1;
|
||||
const pollResp = await fetchImpl(opts.pollUrl, {
|
||||
method: "GET",
|
||||
headers: buildAdobePollHeaders(opts.accessToken),
|
||||
headers: buildAdobePollHeaders(accessToken),
|
||||
});
|
||||
|
||||
if (pollResp.status === 401 || pollResp.status === 403) {
|
||||
@@ -2351,6 +2356,44 @@ async function pollAdobeJob(opts: {
|
||||
"quota_exhausted"
|
||||
);
|
||||
}
|
||||
// One CDP JWT renewal mid-poll (long jobs can outlive a near-expiry IMS token).
|
||||
if (!authRefreshAttempted && opts.sessionCookie) {
|
||||
authRefreshAttempted = true;
|
||||
try {
|
||||
const {
|
||||
rotateAdobeFireflySessionOnError,
|
||||
fingerprintAdobeCredential,
|
||||
estimateAdobeTokenExpiry,
|
||||
} = await import("./adobeFireflySession.ts");
|
||||
const fp =
|
||||
String(opts.sessionFingerprint || "").trim() ||
|
||||
fingerprintAdobeCredential(
|
||||
[accessToken, opts.sessionCookie].filter(Boolean).join("\n")
|
||||
);
|
||||
const refreshed = await rotateAdobeFireflySessionOnError(
|
||||
{
|
||||
accessToken,
|
||||
cookie: opts.sessionCookie,
|
||||
arpSessionId: "",
|
||||
tokenExpiresAt: estimateAdobeTokenExpiry(accessToken),
|
||||
updatedAt: Date.now(),
|
||||
fingerprint: fp,
|
||||
source: "rebuild",
|
||||
},
|
||||
{ attempt: 3, authFailure: true, tryBrowser: true, log: opts.log }
|
||||
);
|
||||
if (refreshed?.accessToken && isAdobeUserAccessToken(refreshed.accessToken)) {
|
||||
accessToken = refreshed.accessToken;
|
||||
opts.log?.info?.(
|
||||
"ADOBE-FIREFLY",
|
||||
`poll auth ${pollResp.status}; retrying once with renewed JWT`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to auth error */
|
||||
}
|
||||
}
|
||||
throw new AdobeFireflyError("Adobe Firefly token invalid or expired", 401, "auth");
|
||||
}
|
||||
|
||||
@@ -2438,6 +2481,8 @@ export async function adobeFireflyGenerateImage(opts: {
|
||||
arpSessionId?: string;
|
||||
/** Session cache key from ensureAdobeFireflySession — sticky ARP across batch jobs. */
|
||||
sessionFingerprint?: string;
|
||||
/** Chrome profile key (provider connection id) for CDP warm/login isolation. */
|
||||
sessionBrowserKey?: string;
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
log?: {
|
||||
@@ -2489,11 +2534,14 @@ export async function adobeFireflyGenerateImage(opts: {
|
||||
const fingerprint =
|
||||
String(opts.sessionFingerprint || "").trim() ||
|
||||
fingerprintAdobeCredential([accessToken, activeCookie].filter(Boolean).join("\n"));
|
||||
const browserSessionKey = String(opts.sessionBrowserKey || "").trim() || fingerprint;
|
||||
|
||||
// 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, {
|
||||
// Gate ONLY the actual generate-async HTTP call (min gap). CDP warm / backoff run
|
||||
// outside so interactive browser login and other Firefly submits are not blocked for minutes.
|
||||
let submitOk = false;
|
||||
for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) {
|
||||
const submitResp = await withAdobeFireflySubmitGate(() =>
|
||||
fetchImpl(ADOBE_FIREFLY_IMAGE_SUBMIT_URL, {
|
||||
method: "POST",
|
||||
headers: buildAdobeSubmitHeaders(accessToken, {
|
||||
arpSessionId,
|
||||
@@ -2501,124 +2549,151 @@ export async function adobeFireflyGenerateImage(opts: {
|
||||
cookie: activeCookie || 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"
|
||||
);
|
||||
}
|
||||
if (!authRefreshAttempted && attempt < SUBMIT_MAX_ATTEMPTS) {
|
||||
authRefreshAttempted = true;
|
||||
const refreshed = await rotateAdobeFireflySessionOnError(
|
||||
{
|
||||
accessToken,
|
||||
cookie: activeCookie,
|
||||
arpSessionId,
|
||||
tokenExpiresAt: estimateAdobeTokenExpiry(accessToken),
|
||||
updatedAt: Date.now(),
|
||||
fingerprint,
|
||||
source: "rebuild",
|
||||
},
|
||||
{ attempt, authFailure: true, tryBrowser: true, log: opts.log }
|
||||
).catch(() => null);
|
||||
if (refreshed?.accessToken && refreshed?.arpSessionId) {
|
||||
accessToken = refreshed.accessToken;
|
||||
activeCookie = refreshed.cookie || activeCookie;
|
||||
arpSessionId = refreshed.arpSessionId;
|
||||
opts.log?.info?.(
|
||||
"ADOBE-FIREFLY",
|
||||
`image submit auth ${submitResp.status}; retrying once with renewed CDP session`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
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 session is no longer authenticated and automatic browser renewal failed. " +
|
||||
"Sign in once through the Adobe Firefly browser login to restore durable renewal.",
|
||||
401,
|
||||
"auth"
|
||||
"Adobe Firefly quota exhausted for this account",
|
||||
429,
|
||||
"quota_exhausted"
|
||||
);
|
||||
}
|
||||
|
||||
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 (activeCookie) {
|
||||
const rotated = await rotateAdobeFireflySessionOnError(
|
||||
{
|
||||
accessToken,
|
||||
cookie: activeCookie,
|
||||
arpSessionId,
|
||||
tokenExpiresAt: estimateAdobeTokenExpiry(accessToken),
|
||||
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;
|
||||
activeCookie = rotated.cookie || activeCookie;
|
||||
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);
|
||||
if (!authRefreshAttempted && attempt < SUBMIT_MAX_ATTEMPTS) {
|
||||
authRefreshAttempted = true;
|
||||
const refreshed = await rotateAdobeFireflySessionOnError(
|
||||
{
|
||||
accessToken,
|
||||
cookie: activeCookie,
|
||||
arpSessionId,
|
||||
tokenExpiresAt: estimateAdobeTokenExpiry(accessToken),
|
||||
updatedAt: Date.now(),
|
||||
fingerprint,
|
||||
browserSessionKey,
|
||||
source: "rebuild",
|
||||
},
|
||||
{ attempt, authFailure: true, tryBrowser: true, log: opts.log }
|
||||
).catch(() => null);
|
||||
if (refreshed?.accessToken && refreshed?.arpSessionId) {
|
||||
accessToken = refreshed.accessToken;
|
||||
activeCookie = refreshed.cookie || activeCookie;
|
||||
arpSessionId = refreshed.arpSessionId;
|
||||
opts.log?.info?.(
|
||||
"ADOBE-FIREFLY",
|
||||
`image submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms (recovery attempt=${attempt})`
|
||||
`image submit auth ${submitResp.status}; retrying once with renewed CDP session`
|
||||
);
|
||||
await sleep(delay);
|
||||
continue;
|
||||
}
|
||||
noteAdobeFireflySubmitFailure();
|
||||
if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) {
|
||||
}
|
||||
throw new AdobeFireflyError(
|
||||
"Adobe Firefly session is no longer authenticated and automatic browser renewal failed. " +
|
||||
"Sign in once through the Adobe Firefly browser login to restore durable renewal.",
|
||||
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) {
|
||||
const { getAdobeForterAgeMs: forterAgeMsFn, extractAdobeForterTimestampMs: forterTsFn } =
|
||||
await import("./adobeFireflySession.ts");
|
||||
// Only treat as known-stale when the cookie embeds a parseable forter timestamp.
|
||||
// Missing timestamp (tests / synthetic ARP) must keep the full retry ladder.
|
||||
const forterTs = forterTsFn(activeCookie || "");
|
||||
const forterAgeBefore = forterAgeMsFn(activeCookie || "");
|
||||
const forterKnownStale =
|
||||
forterTs > 0 && Number.isFinite(forterAgeBefore) && forterAgeBefore > 4 * 60_000;
|
||||
// Stale risk session: at most 2 attempts (warm once + one retry). Avoid ~600s thrash.
|
||||
if (forterKnownStale && attempt >= 2) {
|
||||
noteAdobeFireflySubmitFailure();
|
||||
throw new AdobeFireflyError(
|
||||
formatAdobeSystemUnderLoadError("image", attempt, {
|
||||
hadBrowserArp,
|
||||
}),
|
||||
formatAdobeSystemUnderLoadError("image", attempt, { hadBrowserArp }) +
|
||||
" Risk session looks expired — open Providers → Adobe Firefly → Sign in with browser once.",
|
||||
408,
|
||||
"system_under_load"
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (activeCookie) {
|
||||
const rotated = await rotateAdobeFireflySessionOnError(
|
||||
{
|
||||
accessToken,
|
||||
cookie: activeCookie,
|
||||
arpSessionId,
|
||||
tokenExpiresAt: estimateAdobeTokenExpiry(accessToken),
|
||||
updatedAt: Date.now(),
|
||||
fingerprint,
|
||||
browserSessionKey,
|
||||
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;
|
||||
activeCookie = rotated.cookie || activeCookie;
|
||||
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(
|
||||
lastSubmitError,
|
||||
submitResp.status >= 400 && submitResp.status < 500 ? submitResp.status : 502
|
||||
formatAdobeSystemUnderLoadError("image", attempt, {
|
||||
hadBrowserArp,
|
||||
}),
|
||||
408,
|
||||
"system_under_load"
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
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);
|
||||
submitOk = true;
|
||||
break;
|
||||
}
|
||||
if (!submitOk && !lastSubmitError) {
|
||||
throw new AdobeFireflyError(
|
||||
formatAdobeSystemUnderLoadError("image", SUBMIT_MAX_ATTEMPTS, { hadBrowserArp }),
|
||||
408,
|
||||
"system_under_load"
|
||||
);
|
||||
}
|
||||
|
||||
let pollUrl = extractAdobeResultLink(submitHeaders, submitData);
|
||||
if (!pollUrl) {
|
||||
@@ -2668,6 +2743,8 @@ export async function adobeFireflyGenerateVideo(opts: {
|
||||
arpSessionId?: string;
|
||||
/** Session cache key from ensureAdobeFireflySession — sticky ARP across batch jobs. */
|
||||
sessionFingerprint?: string;
|
||||
/** Chrome profile key (provider connection id) for CDP warm/login isolation. */
|
||||
sessionBrowserKey?: string;
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
log?: {
|
||||
@@ -2734,10 +2811,12 @@ export async function adobeFireflyGenerateVideo(opts: {
|
||||
const fingerprint =
|
||||
String(opts.sessionFingerprint || "").trim() ||
|
||||
fingerprintAdobeCredential([accessToken, activeCookie].filter(Boolean).join("\n"));
|
||||
const browserSessionKey = String(opts.sessionBrowserKey || "").trim() || fingerprint;
|
||||
|
||||
await withAdobeFireflySubmitGate(async () => {
|
||||
for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) {
|
||||
const submitResp = await fetchImpl(ADOBE_FIREFLY_VIDEO_SUBMIT_URL, {
|
||||
let videoSubmitOk = false;
|
||||
for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) {
|
||||
const submitResp = await withAdobeFireflySubmitGate(() =>
|
||||
fetchImpl(ADOBE_FIREFLY_VIDEO_SUBMIT_URL, {
|
||||
method: "POST",
|
||||
headers: buildAdobeSubmitHeaders(accessToken, {
|
||||
arpSessionId,
|
||||
@@ -2745,122 +2824,146 @@ export async function adobeFireflyGenerateVideo(opts: {
|
||||
cookie: activeCookie || 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"
|
||||
);
|
||||
}
|
||||
if (!authRefreshAttempted && attempt < SUBMIT_MAX_ATTEMPTS) {
|
||||
authRefreshAttempted = true;
|
||||
const refreshed = await rotateAdobeFireflySessionOnError(
|
||||
{
|
||||
accessToken,
|
||||
cookie: activeCookie,
|
||||
arpSessionId,
|
||||
tokenExpiresAt: estimateAdobeTokenExpiry(accessToken),
|
||||
updatedAt: Date.now(),
|
||||
fingerprint,
|
||||
source: "rebuild",
|
||||
},
|
||||
{ attempt, authFailure: true, tryBrowser: true, log: opts.log }
|
||||
).catch(() => null);
|
||||
if (refreshed?.accessToken && refreshed?.arpSessionId) {
|
||||
accessToken = refreshed.accessToken;
|
||||
activeCookie = refreshed.cookie || activeCookie;
|
||||
arpSessionId = refreshed.arpSessionId;
|
||||
opts.log?.info?.(
|
||||
"ADOBE-FIREFLY",
|
||||
`video submit auth ${submitResp.status}; retrying once with renewed CDP session`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
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 session is no longer authenticated and automatic browser renewal failed. " +
|
||||
"Sign in once through the Adobe Firefly browser login to restore durable renewal.",
|
||||
401,
|
||||
"auth"
|
||||
"Adobe Firefly quota exhausted for this account",
|
||||
429,
|
||||
"quota_exhausted"
|
||||
);
|
||||
}
|
||||
|
||||
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 (activeCookie) {
|
||||
const rotated = await rotateAdobeFireflySessionOnError(
|
||||
{
|
||||
accessToken,
|
||||
cookie: activeCookie,
|
||||
arpSessionId,
|
||||
tokenExpiresAt: estimateAdobeTokenExpiry(accessToken),
|
||||
updatedAt: Date.now(),
|
||||
fingerprint,
|
||||
source: "rebuild",
|
||||
},
|
||||
{
|
||||
attempt,
|
||||
tryBrowser: process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0",
|
||||
log: opts.log,
|
||||
}
|
||||
);
|
||||
accessToken = rotated.accessToken || accessToken;
|
||||
activeCookie = rotated.cookie || activeCookie;
|
||||
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);
|
||||
if (!authRefreshAttempted && attempt < SUBMIT_MAX_ATTEMPTS) {
|
||||
authRefreshAttempted = true;
|
||||
const refreshed = await rotateAdobeFireflySessionOnError(
|
||||
{
|
||||
accessToken,
|
||||
cookie: activeCookie,
|
||||
arpSessionId,
|
||||
tokenExpiresAt: estimateAdobeTokenExpiry(accessToken),
|
||||
updatedAt: Date.now(),
|
||||
fingerprint,
|
||||
browserSessionKey,
|
||||
source: "rebuild",
|
||||
},
|
||||
{ attempt, authFailure: true, tryBrowser: true, log: opts.log }
|
||||
).catch(() => null);
|
||||
if (refreshed?.accessToken && refreshed?.arpSessionId) {
|
||||
accessToken = refreshed.accessToken;
|
||||
activeCookie = refreshed.cookie || activeCookie;
|
||||
arpSessionId = refreshed.arpSessionId;
|
||||
opts.log?.info?.(
|
||||
"ADOBE-FIREFLY",
|
||||
`video submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms (recovery attempt=${attempt})`
|
||||
`video submit auth ${submitResp.status}; retrying once with renewed CDP session`
|
||||
);
|
||||
await sleep(delay);
|
||||
continue;
|
||||
}
|
||||
noteAdobeFireflySubmitFailure();
|
||||
if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) {
|
||||
}
|
||||
throw new AdobeFireflyError(
|
||||
"Adobe Firefly session is no longer authenticated and automatic browser renewal failed. " +
|
||||
"Sign in once through the Adobe Firefly browser login to restore durable renewal.",
|
||||
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) {
|
||||
const { getAdobeForterAgeMs: forterAgeMsFn, extractAdobeForterTimestampMs: forterTsFn } =
|
||||
await import("./adobeFireflySession.ts");
|
||||
const forterTs = forterTsFn(activeCookie || "");
|
||||
const forterAgeBefore = forterAgeMsFn(activeCookie || "");
|
||||
const forterKnownStale =
|
||||
forterTs > 0 && Number.isFinite(forterAgeBefore) && forterAgeBefore > 4 * 60_000;
|
||||
if (forterKnownStale && attempt >= 2) {
|
||||
noteAdobeFireflySubmitFailure();
|
||||
throw new AdobeFireflyError(
|
||||
formatAdobeSystemUnderLoadError("video", attempt, {
|
||||
hadBrowserArp,
|
||||
}),
|
||||
formatAdobeSystemUnderLoadError("video", attempt, { hadBrowserArp }) +
|
||||
" Risk session looks expired — open Providers → Adobe Firefly → Sign in with browser once.",
|
||||
408,
|
||||
"system_under_load"
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (activeCookie) {
|
||||
const rotated = await rotateAdobeFireflySessionOnError(
|
||||
{
|
||||
accessToken,
|
||||
cookie: activeCookie,
|
||||
arpSessionId,
|
||||
tokenExpiresAt: estimateAdobeTokenExpiry(accessToken),
|
||||
updatedAt: Date.now(),
|
||||
fingerprint,
|
||||
browserSessionKey,
|
||||
source: "rebuild",
|
||||
},
|
||||
{
|
||||
attempt,
|
||||
tryBrowser: process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0",
|
||||
log: opts.log,
|
||||
}
|
||||
);
|
||||
accessToken = rotated.accessToken || accessToken;
|
||||
activeCookie = rotated.cookie || activeCookie;
|
||||
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(
|
||||
lastSubmitError,
|
||||
submitResp.status >= 400 && submitResp.status < 500 ? submitResp.status : 502
|
||||
formatAdobeSystemUnderLoadError("video", attempt, {
|
||||
hadBrowserArp,
|
||||
}),
|
||||
408,
|
||||
"system_under_load"
|
||||
);
|
||||
}
|
||||
|
||||
submitData = await submitResp.json().catch(() => ({}));
|
||||
submitHeaders = submitResp.headers;
|
||||
markAdobeFireflyArpSuccess(fingerprint, arpSessionId);
|
||||
break;
|
||||
throw new AdobeFireflyError(
|
||||
lastSubmitError,
|
||||
submitResp.status >= 400 && submitResp.status < 500 ? submitResp.status : 502
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
submitData = await submitResp.json().catch(() => ({}));
|
||||
submitHeaders = submitResp.headers;
|
||||
markAdobeFireflyArpSuccess(fingerprint, arpSessionId);
|
||||
videoSubmitOk = true;
|
||||
break;
|
||||
}
|
||||
if (!videoSubmitOk && !lastSubmitError) {
|
||||
throw new AdobeFireflyError(
|
||||
formatAdobeSystemUnderLoadError("video", SUBMIT_MAX_ATTEMPTS, { hadBrowserArp }),
|
||||
408,
|
||||
"system_under_load"
|
||||
);
|
||||
}
|
||||
|
||||
let pollUrl = extractAdobeResultLink(submitHeaders, submitData);
|
||||
if (!pollUrl) {
|
||||
@@ -2885,6 +2988,8 @@ export async function adobeFireflyGenerateVideo(opts: {
|
||||
accessToken,
|
||||
kind: "video",
|
||||
timeoutMs: opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_VIDEO_TIMEOUT_MS,
|
||||
sessionCookie: activeCookie || sessionCookie || undefined,
|
||||
sessionFingerprint: fingerprint,
|
||||
fetchImpl,
|
||||
log: opts.log,
|
||||
});
|
||||
|
||||
@@ -72,6 +72,9 @@ 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;
|
||||
@@ -478,6 +481,46 @@ function collectCredentialBlobs(
|
||||
* 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"],
|
||||
@@ -487,48 +530,92 @@ export async function refreshAdobeSessionViaBrowser(
|
||||
// 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,
|
||||
sessionKey: session.browserSessionKey || session.fingerprint,
|
||||
});
|
||||
if (!warmed) return null;
|
||||
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 nextCookie = mergeAdobeCookieHeaders(session.cookie, warmed.cookie || "");
|
||||
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: warmed.accessToken || session.accessToken,
|
||||
accessToken: nextToken,
|
||||
cookie: nextCookie,
|
||||
arpSessionId: nextArp,
|
||||
tokenExpiresAt: estimateAdobeTokenExpiry(warmed.accessToken || session.accessToken),
|
||||
tokenExpiresAt: estimateAdobeTokenExpiry(nextToken),
|
||||
updatedAt: Date.now(),
|
||||
browserSessionKey: session.browserSessionKey || session.fingerprint,
|
||||
source: "browser",
|
||||
};
|
||||
// When warm returns a fresher forter, prefer its cookie entirely for ARP rebuild pieces.
|
||||
const warmFtr = extractAdobeForterTimestampMs(nextCookie);
|
||||
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);
|
||||
void writeBackAdobeFireflyCredentials(next, log);
|
||||
log?.info?.(
|
||||
"ADOBE-FIREFLY",
|
||||
`durable CDP warm refreshed session (arpLen=${next.arpSessionId.length}, force=${force}, forterTs=${warmFtr || 0})`
|
||||
`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)}`
|
||||
@@ -555,19 +642,36 @@ export async function ensureAdobeFireflySession(
|
||||
}
|
||||
|
||||
const joined = blobs.join("\n");
|
||||
const fingerprint = fingerprintAdobeCredential(joined);
|
||||
const browserSessionKey =
|
||||
String(
|
||||
opts.credentials?.connectionId ||
|
||||
opts.credentials?.providerSpecificData?.browserSessionKey ||
|
||||
fingerprint
|
||||
).trim() || fingerprint;
|
||||
// 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);
|
||||
|
||||
const cached = sessionCache.get(fingerprint) || loadDiskSession(fingerprint);
|
||||
if (cached && !opts.forceRefresh) sessionCache.set(fingerprint, cached);
|
||||
// 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 = "";
|
||||
@@ -676,12 +780,14 @@ export async function ensureAdobeFireflySession(
|
||||
accessToken,
|
||||
cookie: cookieForSession,
|
||||
arpSessionId: String(arpSessionId || ""),
|
||||
tokenExpiresAt: estimateAdobeTokenExpiry(accessToken),
|
||||
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
|
||||
@@ -755,8 +861,39 @@ export async function ensureAdobeFireflySession(
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -810,8 +947,9 @@ export async function rotateAdobeFireflySessionOnError(
|
||||
return same;
|
||||
}
|
||||
|
||||
// Known-stale forter or attempt 3+: cookie rebuild is a no-op. Off-screen headed Chrome mints a
|
||||
// fresh Forter/ARP (headless browsers are rejected by colligo, so CDP uses off-screen headed mode).
|
||||
// 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();
|
||||
|
||||
@@ -857,6 +995,7 @@ export function __resetAdobeFireflySessionCacheForTests(): void {
|
||||
sessionCache.clear();
|
||||
browserRefreshInFlight.clear();
|
||||
lastWorkingArpByFingerprint.clear();
|
||||
browserWarmFailureCooldown.clear();
|
||||
lastAdobeSubmitAt = 0;
|
||||
consecutiveAdobeSubmitSuccesses = 0;
|
||||
adobeSubmitChain = Promise.resolve();
|
||||
|
||||
@@ -20,7 +20,142 @@ function resolveProviderSlug(connection: Record<string, unknown> | null): string
|
||||
return "";
|
||||
}
|
||||
|
||||
// тФАтФАтФА POST: Start login flow тФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФА
|
||||
function isAdobeFireflyProvider(
|
||||
connection: { provider?: unknown } | null,
|
||||
providerSlug: string
|
||||
): boolean {
|
||||
const raw = String(connection?.provider || "").trim();
|
||||
return ADOBE_FIREFLY_SLUGS.has(raw) || ADOBE_FIREFLY_SLUGS.has(providerSlug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist JWT + Cookie the way desktop clients (and generate) expect:
|
||||
* multi-line api_key, plus camelCase providerSpecificData for updateProviderConnection.
|
||||
*/
|
||||
async function persistAdobeFireflyCredentials(
|
||||
connectionId: string,
|
||||
opts: {
|
||||
accessToken?: string;
|
||||
cookie?: string;
|
||||
account?: string;
|
||||
arpSessionId?: string;
|
||||
}
|
||||
): Promise<{
|
||||
accessToken: string;
|
||||
cookie: string;
|
||||
credential: string;
|
||||
account: string;
|
||||
}> {
|
||||
const accessToken = String(opts.accessToken || "").trim();
|
||||
const cookie = String(opts.cookie || "").trim();
|
||||
const account = String(opts.account || "").trim();
|
||||
const credential =
|
||||
accessToken && cookie
|
||||
? `${accessToken}\n${cookie}`
|
||||
: accessToken ||
|
||||
cookie ||
|
||||
JSON.stringify({
|
||||
mode: "browser-profile",
|
||||
account,
|
||||
signedInAt: Date.now(),
|
||||
});
|
||||
|
||||
const marker = {
|
||||
mode: "browser-profile",
|
||||
account,
|
||||
signedInAt: Date.now(),
|
||||
arpSessionId: String(opts.arpSessionId || ""),
|
||||
};
|
||||
|
||||
try {
|
||||
// camelCase only — updateProviderConnection / encryptConnectionFields read apiKey +
|
||||
// providerSpecificData (snake_case keys are silently ignored and never persisted).
|
||||
await updateProviderConnection(connectionId, {
|
||||
apiKey: credential,
|
||||
providerSpecificData: {
|
||||
...marker,
|
||||
cookie: cookie || credential,
|
||||
access_token: accessToken || undefined,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
/* non-fatal — return credentials to the host app either way */
|
||||
}
|
||||
|
||||
return { accessToken, cookie, credential, account };
|
||||
}
|
||||
|
||||
function adobeFireflySuccessResponse(data: {
|
||||
accessToken: string;
|
||||
cookie: string;
|
||||
credential: string;
|
||||
account: string;
|
||||
arpSessionId?: string;
|
||||
via: "pure-cdp";
|
||||
}): NextResponse {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
account: data.account || undefined,
|
||||
accessToken: data.accessToken || undefined,
|
||||
cookie: data.cookie || undefined,
|
||||
arpSessionId: data.arpSessionId || undefined,
|
||||
credential: data.credential,
|
||||
credentials: {
|
||||
access_token: data.accessToken || undefined,
|
||||
cookie: data.cookie || undefined,
|
||||
},
|
||||
via: data.via,
|
||||
persisted: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adobe Firefly browser sign-in:
|
||||
* pure system Chrome/Edge CDP only (packaged-safe, no Playwright/browser bundle).
|
||||
*/
|
||||
async function loginAdobeFirefly(
|
||||
connectionId: string,
|
||||
body: { timeout?: unknown; freshSession?: unknown }
|
||||
): Promise<NextResponse> {
|
||||
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
|
||||
const freshSession = typeof body.freshSession === "boolean" ? body.freshSession : true;
|
||||
|
||||
// Pure system-browser CDP is the packaged-safe implementation. Do not open a second browser
|
||||
// after failure: it creates ambiguous success/error races and the packaged runtime has no
|
||||
// reliable Playwright browser bundle.
|
||||
// startAdobeFireflyBrowserLogin always kills its Chrome tree in `finally` (no orphans).
|
||||
try {
|
||||
const { startAdobeFireflyBrowserLogin } =
|
||||
await import("@omniroute/open-sse/services/adobeFireflyBrowserLogin.ts");
|
||||
const pure = await startAdobeFireflyBrowserLogin(timeout, {
|
||||
sessionKey: connectionId,
|
||||
freshSession,
|
||||
});
|
||||
if (pure.success && pure.credentials?.accessToken) {
|
||||
const persisted = await persistAdobeFireflyCredentials(connectionId, {
|
||||
accessToken: pure.credentials.accessToken,
|
||||
cookie: pure.credentials.cookie,
|
||||
account: pure.account,
|
||||
});
|
||||
return adobeFireflySuccessResponse({
|
||||
...persisted,
|
||||
via: "pure-cdp",
|
||||
});
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: pure.error || "Adobe Firefly sign-in did not capture an authenticated IMS JWT.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err);
|
||||
return NextResponse.json({ success: false, error: msg }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// --- POST: Start login flow -------------------------------------------------
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
@@ -39,67 +174,18 @@ export async function POST(
|
||||
timeout?: unknown;
|
||||
freshSession?: unknown;
|
||||
};
|
||||
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
|
||||
const providerSlug = resolveProviderSlug(provider as Record<string, unknown>);
|
||||
|
||||
// Firefly JWTs exist only on firefly-3p Authorization headers. Use one packaged-safe CDP
|
||||
// flow, isolate state by connection, and never fall through to a second browser.
|
||||
if (ADOBE_FIREFLY_SLUGS.has(providerSlug)) {
|
||||
// Adobe Firefly: dedicated JWT capture (never cookies/localStorage alone).
|
||||
if (isAdobeFireflyProvider(provider as { provider?: unknown }, providerSlug)) {
|
||||
try {
|
||||
const { startAdobeFireflyBrowserLogin } =
|
||||
await import("@omniroute/open-sse/services/adobeFireflyBrowserLogin.ts");
|
||||
const result = await startAdobeFireflyBrowserLogin(timeout, {
|
||||
sessionKey: id,
|
||||
freshSession: typeof body.freshSession === "boolean" ? body.freshSession : true,
|
||||
});
|
||||
const accessToken = String(result.credentials?.accessToken || "").trim();
|
||||
const cookie = String(result.credentials?.cookie || "").trim();
|
||||
if (result.success && accessToken) {
|
||||
const credential =
|
||||
accessToken && cookie ? `${accessToken}\n${cookie}` : accessToken || cookie;
|
||||
const marker = {
|
||||
mode: "browser-profile",
|
||||
account: result.account || "",
|
||||
signedInAt: Date.now(),
|
||||
arpSessionId: result.arpSessionId || "",
|
||||
};
|
||||
try {
|
||||
await updateProviderConnection(id, {
|
||||
apiKey: credential,
|
||||
providerSpecificData: {
|
||||
...marker,
|
||||
cookie: 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,
|
||||
credentials: {
|
||||
access_token: accessToken || undefined,
|
||||
cookie: cookie || undefined,
|
||||
},
|
||||
via: "pure-cdp",
|
||||
persisted: true,
|
||||
});
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: result.error || "Adobe Firefly sign-in did not capture an authenticated IMS JWT.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
return await loginAdobeFirefly(id, body);
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err);
|
||||
return NextResponse.json({ success: false, error: msg }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Adobe Firefly sign-in error: ${msg}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +196,9 @@ export async function POST(
|
||||
// missed and returned "No extraction config" without launching a browser.
|
||||
const { inAppLoginService } = await import("@omniroute/open-sse/services/inAppLoginService.ts");
|
||||
|
||||
const result = await inAppLoginService.startLogin(providerSlug || id, { timeout });
|
||||
const result = await inAppLoginService.startLogin(providerSlug || id, {
|
||||
timeout: typeof body.timeout === "number" ? body.timeout : undefined,
|
||||
});
|
||||
|
||||
// Persist credentials if extraction succeeded
|
||||
if (result.success && result.credentials) {
|
||||
|
||||
@@ -5,13 +5,18 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
adobeFireflyBackgroundUsesHeadlessChrome,
|
||||
adobeFireflyBrowserSessionKey,
|
||||
accountLabelFromAdobeJwt,
|
||||
buildAdobeFireflyBrowserArgs,
|
||||
buildAdobeFireflyCookieHeader,
|
||||
clampAdobeFireflyLoginTimeout,
|
||||
extractAdobeBearerTokenFromAuthorization,
|
||||
extractAdobeForterTimestampFromValue,
|
||||
extractUserJwtFromStorageRaw,
|
||||
filterAdobeBrowserCookies,
|
||||
filterSeedCookiesForWarm,
|
||||
isAdobeRiskCookieName,
|
||||
resolveAdobeAccountLabel,
|
||||
resolveSystemBrowserExecutable,
|
||||
} from "../../open-sse/services/adobeFireflyBrowserLogin.ts";
|
||||
@@ -89,7 +94,7 @@ test("resolveAdobeAccountLabel uses IMS display name and generic fallback", asyn
|
||||
assert.equal(fallback, "Adobe account");
|
||||
});
|
||||
|
||||
test("browser args isolate profiles and make fresh interactive login incognito", () => {
|
||||
test("browser args: interactive headed; background offscreen (Forter-safe), headless opt-in only", () => {
|
||||
const firstKey = adobeFireflyBrowserSessionKey("connection-a");
|
||||
const secondKey = adobeFireflyBrowserSessionKey("connection-b");
|
||||
assert.equal(firstKey, adobeFireflyBrowserSessionKey("connection-a"));
|
||||
@@ -101,17 +106,86 @@ test("browser args isolate profiles and make fresh interactive login incognito",
|
||||
interactive: true,
|
||||
freshSession: true,
|
||||
});
|
||||
assert.ok(interactive.includes("--incognito"));
|
||||
// User-initiated Sign in with browser: real window, never headless.
|
||||
assert.equal(interactive.includes("--headless=new"), false);
|
||||
assert.ok(interactive.includes("--new-window"));
|
||||
assert.ok(interactive.includes(`--user-data-dir=C:\\profiles\\${firstKey}`));
|
||||
assert.equal(interactive.at(-1), "https://firefly.adobe.com/");
|
||||
|
||||
const background = buildAdobeFireflyBrowserArgs({
|
||||
port: 9223,
|
||||
userDataDir: `C:\\profiles\\${secondKey}`,
|
||||
interactive: false,
|
||||
});
|
||||
assert.equal(background.includes("--incognito"), false);
|
||||
assert.equal(background.at(-1), "about:blank");
|
||||
const prevHeadless = process.env.ADOBE_FIREFLY_CHROME_HEADLESS;
|
||||
delete process.env.ADOBE_FIREFLY_CHROME_HEADLESS;
|
||||
try {
|
||||
// Default background: offscreen headed (colligo accepts; true headless → 408).
|
||||
assert.equal(adobeFireflyBackgroundUsesHeadlessChrome(), false);
|
||||
const background = buildAdobeFireflyBrowserArgs({
|
||||
port: 9223,
|
||||
userDataDir: `C:\\profiles\\${secondKey}`,
|
||||
interactive: false,
|
||||
});
|
||||
assert.equal(background.includes("--headless=new"), false);
|
||||
assert.equal(background.includes("--new-window"), false);
|
||||
assert.ok(background.includes("--window-position=-32000,-32000"));
|
||||
assert.ok(background.includes("--start-minimized"));
|
||||
assert.equal(background.at(-1), "https://firefly.adobe.com/");
|
||||
|
||||
process.env.ADOBE_FIREFLY_CHROME_HEADLESS = "1";
|
||||
assert.equal(adobeFireflyBackgroundUsesHeadlessChrome(), true);
|
||||
const headless = buildAdobeFireflyBrowserArgs({
|
||||
port: 9224,
|
||||
userDataDir: `C:\\profiles\\${secondKey}`,
|
||||
interactive: false,
|
||||
});
|
||||
assert.ok(headless.includes("--headless=new"));
|
||||
} finally {
|
||||
if (prevHeadless === undefined) delete process.env.ADOBE_FIREFLY_CHROME_HEADLESS;
|
||||
else process.env.ADOBE_FIREFLY_CHROME_HEADLESS = prevHeadless;
|
||||
}
|
||||
});
|
||||
|
||||
test("isAdobeRiskCookieName flags forter/arkose/sherlock", () => {
|
||||
assert.equal(isAdobeRiskCookieName("forterToken"), true);
|
||||
assert.equal(isAdobeRiskCookieName("arkose"), true);
|
||||
assert.equal(isAdobeRiskCookieName("sherlockToken"), true);
|
||||
assert.equal(isAdobeRiskCookieName("ff_session_guid"), false);
|
||||
assert.equal(isAdobeRiskCookieName("aux_sid"), false);
|
||||
});
|
||||
|
||||
test("filterSeedCookiesForWarm drops risk cookies on force warm", () => {
|
||||
const filtered = filterSeedCookiesForWarm(
|
||||
[
|
||||
{ name: "forterToken", value: "stale" },
|
||||
{ name: "arkose", value: "a" },
|
||||
{ name: "ff_session_guid", value: "sid" },
|
||||
{ name: "aux_sid", value: "aux" },
|
||||
],
|
||||
{ dropRiskCookies: true }
|
||||
);
|
||||
assert.deepEqual(filtered.map((c) => c.name).sort(), ["aux_sid", "ff_session_guid"]);
|
||||
});
|
||||
|
||||
test("extractAdobeForterTimestampFromValue reads embedded ms", () => {
|
||||
const ftr = "abc_1785777856265__UDF43-mnts-ants-x";
|
||||
assert.equal(extractAdobeForterTimestampFromValue(ftr), 1785777856265);
|
||||
assert.equal(extractAdobeForterTimestampFromValue(""), 0);
|
||||
});
|
||||
|
||||
test("extractUserJwtFromStorageRaw prefers user AdobeID JWT", () => {
|
||||
const guestPayload = Buffer.from(
|
||||
JSON.stringify({ type: "guest", account_type: "guest", user_id: "x@GuestID" })
|
||||
).toString("base64url");
|
||||
const userPayload = Buffer.from(
|
||||
JSON.stringify({
|
||||
type: "access_token",
|
||||
user_id: "0EB6@AdobeID",
|
||||
client_id: "clio-playground-web",
|
||||
created_at: Date.now(),
|
||||
expires_in: 86400000,
|
||||
})
|
||||
).toString("base64url");
|
||||
const guest = `eyJhbGciOiJIUzI1NiJ9.${guestPayload}.sig`;
|
||||
const user = `eyJhbGciOiJSUzI1NiJ9.${userPayload}.usersig`;
|
||||
const raw = JSON.stringify({ tokenValue: guest }) + "\n" + JSON.stringify({ access_token: user });
|
||||
assert.equal(extractUserJwtFromStorageRaw(raw), user);
|
||||
});
|
||||
|
||||
test("filterAdobeBrowserCookies keeps Adobe SSO domains only", () => {
|
||||
|
||||
Reference in New Issue
Block a user