mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-16 04:03:02 +03:00
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.
This commit is contained in:
@@ -1,160 +1,159 @@
|
||||
// Adobe Firefly (unofficial) image-generation handler.
|
||||
// Family: adobe-firefly-image | Provider: adobe-firefly
|
||||
//
|
||||
// Credentials: IMS access_token (JWT, client_id clio-playground-web) or full
|
||||
// Cookie header from firefly.adobe.com. Cookie → IMS check/v6/token with
|
||||
// client_id clio-playground-web (Express projectx_webapp fallback).
|
||||
//
|
||||
// Reference images (Media page / OpenAI edit aliases):
|
||||
// 1) POST raw bytes → firefly-3p /v2/storage/image → { images:[{ id }] }
|
||||
// 2) generate-async with referenceBlobs:[{ id, usage:"general"|"subject" }]
|
||||
// See web_providers/adobe_atach_images.txt for live captures.
|
||||
|
||||
import { sanitizeErrorMessage } from "../../../utils/error.ts";
|
||||
import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts";
|
||||
import {
|
||||
AdobeFireflyError,
|
||||
adobeFireflyGenerateImage,
|
||||
resolveAdobeAccessToken,
|
||||
resolveAdobeArpSessionId,
|
||||
resolveAdobeSourceImageIds,
|
||||
resolveAdobeImageModel,
|
||||
} from "../../../services/adobeFireflyClient.ts";
|
||||
|
||||
function normalizePositiveNumber(value: unknown, fallback: number): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
export async function handleAdobeFireflyImageGeneration({
|
||||
model,
|
||||
provider,
|
||||
body,
|
||||
credentials,
|
||||
log,
|
||||
fetchImpl = fetch,
|
||||
}: {
|
||||
model: string;
|
||||
provider: string;
|
||||
providerConfig?: { baseUrl?: string };
|
||||
body: {
|
||||
prompt?: unknown;
|
||||
size?: unknown;
|
||||
aspect_ratio?: unknown;
|
||||
aspectRatio?: unknown;
|
||||
quality?: unknown;
|
||||
seed?: unknown;
|
||||
negative_prompt?: unknown;
|
||||
timeout_ms?: unknown;
|
||||
image?: unknown;
|
||||
image_url?: unknown;
|
||||
image_urls?: unknown;
|
||||
images?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
credentials: { apiKey?: string; accessToken?: string };
|
||||
log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void };
|
||||
fetchImpl?: typeof fetch;
|
||||
}) {
|
||||
const startTime = Date.now();
|
||||
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
|
||||
if (!prompt) {
|
||||
return saveImageErrorResult({
|
||||
provider,
|
||||
model,
|
||||
status: 400,
|
||||
startTime,
|
||||
error: "Prompt is required for Adobe Firefly image generation",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await resolveAdobeAccessToken(credentials, fetchImpl);
|
||||
const timeoutMs = normalizePositiveNumber(body.timeout_ms, 180_000);
|
||||
const seed =
|
||||
typeof body.seed === "number"
|
||||
? body.seed
|
||||
: typeof body.seed === "string" && body.seed.trim()
|
||||
? Number(body.seed)
|
||||
: undefined;
|
||||
|
||||
// Keep the raw credential blob for Cookie + sherlockToken (x-arp-session-id).
|
||||
// JWT may be embedded in the same paste as cookies (HAR / multi-line).
|
||||
const psd = (credentials as { providerSpecificData?: { cookie?: string } })
|
||||
?.providerSpecificData;
|
||||
const sessionCookie =
|
||||
(typeof psd?.cookie === "string" && psd.cookie.trim()) ||
|
||||
(typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) ||
|
||||
(typeof credentials?.accessToken === "string" && credentials.accessToken.includes(";")
|
||||
? credentials.accessToken
|
||||
: undefined);
|
||||
|
||||
// Cap uploads by model family (matches MediaViewModel GetSourceImageLimit).
|
||||
const { id: resolvedId } = resolveAdobeImageModel(model);
|
||||
const maxRefs = resolvedId.includes("nano-banana") || resolvedId.includes("gpt-image") ? 4 : 2;
|
||||
|
||||
// One ARP for upload+generate (browser reuses sherlockToken / x-arp-session-id).
|
||||
const arpSessionId = resolveAdobeArpSessionId(sessionCookie);
|
||||
|
||||
const sourceImageIds = await resolveAdobeSourceImageIds({
|
||||
accessToken,
|
||||
body,
|
||||
max: maxRefs,
|
||||
sessionCookie,
|
||||
arpSessionId,
|
||||
prompt,
|
||||
fetchImpl,
|
||||
log,
|
||||
});
|
||||
|
||||
log?.info?.(
|
||||
"IMAGE",
|
||||
`${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` +
|
||||
(sourceImageIds.length ? ` | refs: ${sourceImageIds.length}` : "")
|
||||
);
|
||||
|
||||
const result = await adobeFireflyGenerateImage({
|
||||
accessToken,
|
||||
prompt,
|
||||
model,
|
||||
size: body.size,
|
||||
aspectRatio: body.aspect_ratio ?? body.aspectRatio ?? body.size,
|
||||
quality: body.quality,
|
||||
seed: Number.isFinite(seed as number) ? (seed as number) : undefined,
|
||||
negativePrompt: typeof body.negative_prompt === "string" ? body.negative_prompt : undefined,
|
||||
sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined,
|
||||
sessionCookie,
|
||||
arpSessionId,
|
||||
timeoutMs,
|
||||
fetchImpl,
|
||||
log,
|
||||
});
|
||||
|
||||
return saveImageSuccessResult({
|
||||
provider,
|
||||
model,
|
||||
startTime,
|
||||
images: [{ url: result.url }],
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AdobeFireflyError) {
|
||||
log?.error?.("IMAGE", `${provider} adobe-firefly error ${err.status}: ${err.message}`);
|
||||
return saveImageErrorResult({
|
||||
provider,
|
||||
model,
|
||||
status: err.status,
|
||||
startTime,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
log?.error?.("IMAGE", `${provider} adobe-firefly exception: ${errorText}`);
|
||||
return saveImageErrorResult({
|
||||
provider,
|
||||
model,
|
||||
status: 500,
|
||||
startTime,
|
||||
error: errorText,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Adobe Firefly (unofficial) image-generation handler.
|
||||
// Family: adobe-firefly-image | Provider: adobe-firefly
|
||||
//
|
||||
// Credentials: IMS access_token (JWT, client_id clio-playground-web) or full
|
||||
// Cookie header from firefly.adobe.com. Cookie → IMS check/v6/token with
|
||||
// client_id clio-playground-web (Express projectx_webapp fallback).
|
||||
//
|
||||
// Reference images (Media page / OpenAI edit aliases):
|
||||
// 1) POST raw bytes → firefly-3p /v2/storage/image → { images:[{ id }] }
|
||||
// 2) generate-async with referenceBlobs:[{ id, usage:"general"|"subject" }]
|
||||
// See browser network capture for live captures.
|
||||
|
||||
import { sanitizeErrorMessage } from "../../../utils/error.ts";
|
||||
import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts";
|
||||
import {
|
||||
AdobeFireflyError,
|
||||
adobeFireflyGenerateImage,
|
||||
resolveAdobeSourceImageIds,
|
||||
resolveAdobeImageModel,
|
||||
} from "../../../services/adobeFireflyClient.ts";
|
||||
import { ensureAdobeFireflySession } from "../../../services/adobeFireflySession.ts";
|
||||
|
||||
function normalizePositiveNumber(value: unknown, fallback: number): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
export async function handleAdobeFireflyImageGeneration({
|
||||
model,
|
||||
provider,
|
||||
body,
|
||||
credentials,
|
||||
log,
|
||||
fetchImpl = fetch,
|
||||
}: {
|
||||
model: string;
|
||||
provider: string;
|
||||
providerConfig?: { baseUrl?: string };
|
||||
body: {
|
||||
prompt?: unknown;
|
||||
size?: unknown;
|
||||
aspect_ratio?: unknown;
|
||||
aspectRatio?: unknown;
|
||||
quality?: unknown;
|
||||
seed?: unknown;
|
||||
negative_prompt?: unknown;
|
||||
timeout_ms?: unknown;
|
||||
image?: unknown;
|
||||
image_url?: unknown;
|
||||
image_urls?: unknown;
|
||||
images?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
credentials: { apiKey?: string; accessToken?: string };
|
||||
log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void };
|
||||
fetchImpl?: typeof fetch;
|
||||
}) {
|
||||
const startTime = Date.now();
|
||||
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
|
||||
if (!prompt) {
|
||||
return saveImageErrorResult({
|
||||
provider,
|
||||
model,
|
||||
status: 400,
|
||||
startTime,
|
||||
error: "Prompt is required for Adobe Firefly image generation",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Durable session: JWT + Cookie once → auto-rebuild ARP from forter/arkose,
|
||||
// cache, optional Playwright warm-up. Submit path rotates ARP on 408.
|
||||
const session = await ensureAdobeFireflySession({
|
||||
credentials,
|
||||
fetchImpl,
|
||||
log,
|
||||
});
|
||||
const accessToken = session.accessToken;
|
||||
const sessionCookie = session.cookie || undefined;
|
||||
const arpSessionId = session.arpSessionId;
|
||||
const timeoutMs = normalizePositiveNumber(body.timeout_ms, 180_000);
|
||||
const seed =
|
||||
typeof body.seed === "number"
|
||||
? body.seed
|
||||
: typeof body.seed === "string" && body.seed.trim()
|
||||
? Number(body.seed)
|
||||
: undefined;
|
||||
|
||||
// Cap uploads by model family (matches MediaViewModel GetSourceImageLimit).
|
||||
const { id: resolvedId } = resolveAdobeImageModel(model);
|
||||
const maxRefs =
|
||||
resolvedId.includes("nano-banana") || resolvedId.includes("gpt-image")
|
||||
? 4
|
||||
: 2;
|
||||
|
||||
const sourceImageIds = await resolveAdobeSourceImageIds({
|
||||
accessToken,
|
||||
body,
|
||||
max: maxRefs,
|
||||
sessionCookie,
|
||||
arpSessionId,
|
||||
prompt,
|
||||
fetchImpl,
|
||||
log,
|
||||
});
|
||||
|
||||
log?.info?.(
|
||||
"IMAGE",
|
||||
`${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` +
|
||||
(sourceImageIds.length ? ` | refs: ${sourceImageIds.length}` : "") +
|
||||
` | session=${session.source}`
|
||||
);
|
||||
|
||||
const result = await adobeFireflyGenerateImage({
|
||||
accessToken,
|
||||
prompt,
|
||||
model,
|
||||
size: body.size,
|
||||
aspectRatio: body.aspect_ratio ?? body.aspectRatio ?? body.size,
|
||||
quality: body.quality,
|
||||
seed: Number.isFinite(seed as number) ? (seed as number) : undefined,
|
||||
negativePrompt:
|
||||
typeof body.negative_prompt === "string" ? body.negative_prompt : undefined,
|
||||
sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined,
|
||||
sessionCookie,
|
||||
arpSessionId,
|
||||
timeoutMs,
|
||||
fetchImpl,
|
||||
log,
|
||||
});
|
||||
|
||||
return saveImageSuccessResult({
|
||||
provider,
|
||||
model,
|
||||
startTime,
|
||||
images: [{ url: result.url }],
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AdobeFireflyError) {
|
||||
log?.error?.("IMAGE", `${provider} adobe-firefly error ${err.status}: ${err.message}`);
|
||||
return saveImageErrorResult({
|
||||
provider,
|
||||
model,
|
||||
status: err.status,
|
||||
startTime,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
log?.error?.("IMAGE", `${provider} adobe-firefly exception: ${errorText}`);
|
||||
return saveImageErrorResult({
|
||||
provider,
|
||||
model,
|
||||
status: 500,
|
||||
startTime,
|
||||
error: errorText,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,10 @@ import { sanitizeErrorMessage } from "../../utils/error.ts";
|
||||
import {
|
||||
AdobeFireflyError,
|
||||
adobeFireflyGenerateVideo,
|
||||
resolveAdobeAccessToken,
|
||||
resolveAdobeArpSessionId,
|
||||
resolveAdobeSourceImageIds,
|
||||
resolveAdobeVideoModel,
|
||||
} from "../../services/adobeFireflyClient.ts";
|
||||
import { ensureAdobeFireflySession } from "../../services/adobeFireflySession.ts";
|
||||
|
||||
function normalizePositiveNumber(value: unknown, fallback: number): number {
|
||||
const n = Number(value);
|
||||
@@ -47,7 +46,14 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await resolveAdobeAccessToken(credentials, fetchImpl);
|
||||
const session = await ensureAdobeFireflySession({
|
||||
credentials,
|
||||
fetchImpl,
|
||||
log,
|
||||
});
|
||||
const accessToken = session.accessToken;
|
||||
const sessionCookie = session.cookie || undefined;
|
||||
const arpSessionId = session.arpSessionId;
|
||||
const timeoutMs = normalizePositiveNumber(body.timeout_ms, 300_000);
|
||||
const seed =
|
||||
typeof body.seed === "number"
|
||||
@@ -55,21 +61,10 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
: typeof body.seed === "string" && String(body.seed).trim()
|
||||
? Number(body.seed)
|
||||
: undefined;
|
||||
// Keep raw paste for Cookie + sherlockToken (x-arp-session-id).
|
||||
const psd = (credentials as { providerSpecificData?: { cookie?: string } })
|
||||
?.providerSpecificData;
|
||||
const sessionCookie =
|
||||
(typeof psd?.cookie === "string" && psd.cookie.trim()) ||
|
||||
(typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) ||
|
||||
(typeof credentials?.accessToken === "string" && credentials.accessToken.includes(";")
|
||||
? credentials.accessToken
|
||||
: undefined);
|
||||
|
||||
// Kling i2v / Veo ref / Sora frame: upload reference images first.
|
||||
const { id: videoModelId } = resolveAdobeVideoModel(String(model));
|
||||
const maxFrames = videoModelId.includes("kling") || videoModelId.includes("sora") ? 2 : 3;
|
||||
// One ARP for frame upload + video submit (matches browser).
|
||||
const arpSessionId = resolveAdobeArpSessionId(sessionCookie);
|
||||
const sourceImageIds = await resolveAdobeSourceImageIds({
|
||||
accessToken,
|
||||
body,
|
||||
@@ -84,7 +79,8 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
log?.info?.(
|
||||
"VIDEO",
|
||||
`${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` +
|
||||
(sourceImageIds.length ? ` | frames: ${sourceImageIds.length}` : "")
|
||||
(sourceImageIds.length ? ` | frames: ${sourceImageIds.length}` : "") +
|
||||
` | session=${session.source}`
|
||||
);
|
||||
|
||||
const result = await adobeFireflyGenerateVideo({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
652
open-sse/services/adobeFireflySession.ts
Normal file
652
open-sse/services/adobeFireflySession.ts
Normal file
@@ -0,0 +1,652 @@
|
||||
/**
|
||||
* Adobe Firefly durable session manager.
|
||||
*
|
||||
* Goal: user pastes JWT and/or browser Cookie once; we keep generate working by:
|
||||
* 1) Extracting / caching the IMS user JWT (24h typical)
|
||||
* 2) Rebuilding x-arp-session-id from live cookie pieces (ff_session_guid + arkose +
|
||||
* forterToken + optional bfp/fpjs) — the SPA's sherlockToken is just that blob
|
||||
* 3) Optionally warming forter/arkose via Playwright against firefly.adobe.com
|
||||
* 4) Merging Set-Cookie / jar updates back into the stored cookie string
|
||||
* 5) Rotating ARP on colligo 408 retries (stale Arkose/Forter is the usual cause)
|
||||
*
|
||||
* Firefly.adobe.com page cookies alone still cannot mint a user IMS token (IMS cookies
|
||||
* live on adobelogin.com). JWT paste once covers that; ARP is what expires every few
|
||||
* minutes and must be auto-rebuilt.
|
||||
*/
|
||||
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
AdobeFireflyError,
|
||||
buildAdobeArpSessionId,
|
||||
extractAdobeArpSessionId,
|
||||
extractAdobeCookieHeader,
|
||||
extractAdobeCredentialToken,
|
||||
isAdobeUserAccessToken,
|
||||
looksLikeAdobeCookieBlob,
|
||||
looksLikeAdobeJwt,
|
||||
decodeAdobeJwtPayload,
|
||||
resolveAdobeAccessToken,
|
||||
exchangeAdobeCookieForAccessToken,
|
||||
} from "./adobeFireflyClient.ts";
|
||||
|
||||
export interface AdobeFireflySession {
|
||||
accessToken: string;
|
||||
cookie: string;
|
||||
arpSessionId: string;
|
||||
/** Epoch ms when the IMS token is expected to expire (best-effort). */
|
||||
tokenExpiresAt: number;
|
||||
updatedAt: number;
|
||||
/** Hash of the original credential paste (cache key). */
|
||||
fingerprint: string;
|
||||
source: "paste" | "ims" | "browser" | "cache" | "rebuild";
|
||||
}
|
||||
|
||||
export interface AdobeFireflySessionResolveOpts {
|
||||
credentials?: {
|
||||
apiKey?: string;
|
||||
accessToken?: string;
|
||||
providerSpecificData?: { cookie?: unknown; access_token?: unknown; accessToken?: unknown } | null;
|
||||
} | null;
|
||||
/** Force browser / cookie ARP rebuild (e.g. after HTTP 408). */
|
||||
forceRefresh?: boolean;
|
||||
/** Prefer minting a brand-new ARP (retry path). */
|
||||
rotateArp?: boolean;
|
||||
fetchImpl?: typeof fetch;
|
||||
log?: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void };
|
||||
/** Disable Playwright refresh (tests / hosts without browsers). */
|
||||
allowBrowserRefresh?: boolean;
|
||||
}
|
||||
|
||||
const sessionCache = new Map<string, AdobeFireflySession>();
|
||||
const browserRefreshInFlight = new Map<string, Promise<AdobeFireflySession | null>>();
|
||||
|
||||
/** ARP / sherlock is short-lived; refresh before this age when cookies can rebuild. */
|
||||
const ARP_MAX_AGE_MS = 90_000;
|
||||
/** Refresh IMS token this many ms before JWT expiry. */
|
||||
const JWT_REFRESH_SKEW_MS = 10 * 60_000;
|
||||
/** Persist sessions under DATA_DIR so restarts keep JWT + last cookie. */
|
||||
const SESSION_DIR_NAME = "adobe-firefly-sessions";
|
||||
|
||||
function dataDir(): string {
|
||||
return (
|
||||
String(process.env.DATA_DIR || process.env.OMNIROUTE_DATA_DIR || "").trim() ||
|
||||
join(process.cwd(), ".data")
|
||||
);
|
||||
}
|
||||
|
||||
function sessionFilePath(fingerprint: string): string {
|
||||
const dir = join(dataDir(), SESSION_DIR_NAME);
|
||||
try {
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return join(dir, `${fingerprint}.json`);
|
||||
}
|
||||
|
||||
export function fingerprintAdobeCredential(raw: string): string {
|
||||
return createHash("sha256").update(String(raw || "").trim()).digest("hex").slice(0, 32);
|
||||
}
|
||||
|
||||
/** Pull a single cookie value from a Cookie header / paste blob. */
|
||||
export function getAdobeCookieValue(cookieOrBlob: string, name: string): string {
|
||||
const raw = String(cookieOrBlob || "");
|
||||
if (!raw || !name) return "";
|
||||
const re = new RegExp(`(?:^|[;\\s\\n\\r])${name.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}=([^;\\s\\n\\r]+)`, "i");
|
||||
const m = raw.match(re);
|
||||
if (!m?.[1]) return "";
|
||||
let v = m[1].trim().replace(/^["']|["']$/g, "");
|
||||
try {
|
||||
if (/%[0-9A-Fa-f]{2}/.test(v)) v = decodeURIComponent(v);
|
||||
} catch {
|
||||
/* keep */
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Normalize Forter token to the live ftr shape ending in -v2_tt. */
|
||||
export function normalizeAdobeForterToken(value: string): string {
|
||||
let f = String(value || "").trim();
|
||||
if (!f) return "";
|
||||
try {
|
||||
if (/%[0-9A-Fa-f]{2}/.test(f)) f = decodeURIComponent(f);
|
||||
} catch {
|
||||
/* keep */
|
||||
}
|
||||
// Cookie sometimes stores "id,timestamp" (localStorage form) — not usable as ftr.
|
||||
if (/^[a-f0-9]{32},\d+$/i.test(f)) return "";
|
||||
if (f.endsWith("v2") && !f.endsWith("v2_tt")) f = `${f}_tt`;
|
||||
return f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild x-arp-session-id from browser cookie components.
|
||||
* Live successful generate-async ARP is base64(JSON({sid, ark, ftr, bfp?, fpjs?})).
|
||||
* Returns "" when required pieces are missing.
|
||||
*/
|
||||
export function buildAdobeArpSessionIdFromCookies(
|
||||
cookieOrBlob: string,
|
||||
extras?: { region?: string; bfp?: string; fpjs?: string }
|
||||
): string {
|
||||
const blob = String(cookieOrBlob || "");
|
||||
if (!blob.trim()) return "";
|
||||
|
||||
const sid =
|
||||
getAdobeCookieValue(blob, "ff_session_guid") ||
|
||||
getAdobeCookieValue(blob, "sid") ||
|
||||
"";
|
||||
const ark = getAdobeCookieValue(blob, "arkose") || "";
|
||||
const ftr =
|
||||
normalizeAdobeForterToken(getAdobeCookieValue(blob, "forterToken")) ||
|
||||
normalizeAdobeForterToken(getAdobeCookieValue(blob, "forter")) ||
|
||||
"";
|
||||
if (!sid || !ark || !ftr) return "";
|
||||
|
||||
let bfp = extras?.bfp || getAdobeCookieValue(blob, "bfp") || "";
|
||||
let fpjsRaw = extras?.fpjs || getAdobeCookieValue(blob, "fpjs") || "";
|
||||
if (fpjsRaw) {
|
||||
try {
|
||||
if (/%[0-9A-Fa-f]{2}/.test(fpjsRaw)) fpjsRaw = decodeURIComponent(fpjsRaw);
|
||||
} catch {
|
||||
/* keep */
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer rebuilding over a stale sherlockToken when cookie pieces exist —
|
||||
// forterToken timestamps advance as the SPA warms risk SDKs.
|
||||
const obj: Record<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 the multi-line credential paste application stores. */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional Playwright warm-up: open firefly.adobe.com with the user's cookies so
|
||||
* Forter/Arkose mint fresh tokens, then rebuild ARP + merge the jar.
|
||||
* Never throws — returns null when Playwright is unavailable or warm-up fails.
|
||||
*/
|
||||
export async function refreshAdobeSessionViaBrowser(
|
||||
session: AdobeFireflySession,
|
||||
log?: AdobeFireflySessionResolveOpts["log"]
|
||||
): Promise<AdobeFireflySession | null> {
|
||||
if (process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "0") return null;
|
||||
|
||||
let chromium: typeof import("playwright").chromium | null = null;
|
||||
try {
|
||||
const pw = await import("playwright");
|
||||
chromium = pw.chromium;
|
||||
} catch {
|
||||
log?.warn?.("ADOBE-FIREFLY", "Playwright not available — skip browser ARP refresh");
|
||||
return null;
|
||||
}
|
||||
|
||||
let browser: import("playwright").Browser | null = null;
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ["--disable-blink-features=AutomationControlled"],
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36",
|
||||
locale: "en-US",
|
||||
viewport: { width: 1280, height: 800 },
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "webdriver", { get: () => undefined });
|
||||
});
|
||||
|
||||
const cookieHeader = extractAdobeCookieHeader(session.cookie) || session.cookie;
|
||||
for (const part of cookieHeader.split(";")) {
|
||||
const idx = part.indexOf("=");
|
||||
if (idx <= 0) continue;
|
||||
let name = part.slice(0, idx).trim();
|
||||
let value = part.slice(idx + 1).trim();
|
||||
try {
|
||||
name = decodeURIComponent(name);
|
||||
} catch {
|
||||
/* keep */
|
||||
}
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (!name || /[\r\n\0]/.test(value)) continue;
|
||||
try {
|
||||
await context.addCookies([
|
||||
{ name, value, domain: ".adobe.com", path: "/", secure: true, sameSite: "Lax" },
|
||||
]);
|
||||
} catch {
|
||||
try {
|
||||
await context.addCookies([
|
||||
{ name, value, url: "https://firefly.adobe.com/", path: "/", secure: true },
|
||||
]);
|
||||
} catch {
|
||||
/* skip invalid cookie */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto("https://firefly.adobe.com/generate/image", {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 45_000,
|
||||
});
|
||||
|
||||
// Inject stored user JWT so SPA API calls (if any) use AdobeID, not guest.
|
||||
if (session.accessToken) {
|
||||
await page
|
||||
.evaluate((token) => {
|
||||
for (const key of Object.keys(sessionStorage)) {
|
||||
if (!key.includes("adobeid_ims_access_token/clio-playground-web")) continue;
|
||||
let obj: Record<string, unknown> = {};
|
||||
try {
|
||||
obj = JSON.parse(sessionStorage.getItem(key) || "{}") as Record<string, unknown>;
|
||||
} catch {
|
||||
obj = {};
|
||||
}
|
||||
obj.tokenValue = token;
|
||||
obj.access_token = token;
|
||||
obj.valid = true;
|
||||
obj.expire = Date.now() + 20 * 3600 * 1000;
|
||||
obj.expires_in = 86400000;
|
||||
obj.client_id = "clio-playground-web";
|
||||
sessionStorage.setItem(key, JSON.stringify(obj));
|
||||
}
|
||||
}, session.accessToken)
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Wait for Forter / Arkose warm-up.
|
||||
await page.waitForTimeout(6_000);
|
||||
|
||||
const jar = await context.cookies();
|
||||
const jarHeader = jar.map((c) => `${c.name}=${c.value}`).join("; ");
|
||||
const ls = await page
|
||||
.evaluate(() => ({
|
||||
bfp: localStorage.getItem("bfp") || "",
|
||||
fpjs: localStorage.getItem("fpjs") || "",
|
||||
forter: localStorage.getItem("forterToken") || "",
|
||||
}))
|
||||
.catch(() => ({ bfp: "", fpjs: "", forter: "" }));
|
||||
|
||||
const mergedCookie = mergeAdobeCookieHeaders(session.cookie, jarHeader);
|
||||
// Ensure bfp/fpjs land in the cookie blob for rebuild if only in localStorage
|
||||
let blobForArp = mergedCookie;
|
||||
if (ls.bfp && !getAdobeCookieValue(blobForArp, "bfp")) {
|
||||
blobForArp = mergeAdobeCookieHeaders(blobForArp, `bfp=${ls.bfp}`);
|
||||
}
|
||||
if (ls.fpjs && !getAdobeCookieValue(blobForArp, "fpjs")) {
|
||||
blobForArp = mergeAdobeCookieHeaders(blobForArp, `fpjs=${ls.fpjs}`);
|
||||
}
|
||||
|
||||
const arp =
|
||||
buildAdobeArpSessionIdFromCookies(blobForArp, {
|
||||
bfp: ls.bfp || undefined,
|
||||
fpjs: ls.fpjs || undefined,
|
||||
}) ||
|
||||
extractAdobeArpSessionId(blobForArp) ||
|
||||
buildAdobeArpSessionId();
|
||||
|
||||
const next: AdobeFireflySession = {
|
||||
...session,
|
||||
cookie: extractAdobeCookieHeader(blobForArp) || blobForArp,
|
||||
arpSessionId: arp,
|
||||
updatedAt: Date.now(),
|
||||
source: "browser",
|
||||
};
|
||||
sessionCache.set(session.fingerprint, next);
|
||||
saveDiskSession(next);
|
||||
log?.info?.("ADOBE-FIREFLY", "browser session warm-up refreshed ARP/cookie");
|
||||
return next;
|
||||
} catch (err) {
|
||||
log?.warn?.(
|
||||
"ADOBE-FIREFLY",
|
||||
`browser ARP refresh failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
if (browser) {
|
||||
try {
|
||||
await browser.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a durable Firefly session from stored credentials.
|
||||
* Caches in memory + DATA_DIR; rebuilds ARP from cookies; optionally warms via Playwright.
|
||||
*/
|
||||
export async function ensureAdobeFireflySession(
|
||||
opts: AdobeFireflySessionResolveOpts
|
||||
): Promise<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");
|
||||
const fingerprint = fingerprintAdobeCredential(joined);
|
||||
|
||||
// forceRefresh / rotate always drop in-memory cache for this fingerprint
|
||||
if (opts.forceRefresh) sessionCache.delete(fingerprint);
|
||||
|
||||
const cached = sessionCache.get(fingerprint) || loadDiskSession(fingerprint);
|
||||
if (cached && !opts.forceRefresh) sessionCache.set(fingerprint, cached);
|
||||
|
||||
const fetchImpl = opts.fetchImpl || fetch;
|
||||
let accessToken = "";
|
||||
let cookie = "";
|
||||
let pasteHadUserJwt = false;
|
||||
|
||||
// Prefer JWT from the live paste (authoritative for this request)
|
||||
for (const b of blobs) {
|
||||
const tok = extractAdobeCredentialToken(b);
|
||||
if (looksLikeAdobeJwt(tok) && isAdobeUserAccessToken(tok)) {
|
||||
accessToken = tok;
|
||||
pasteHadUserJwt = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Cookie-only paste: use short-lived memory cache JWT only (not a stale disk token alone)
|
||||
if (
|
||||
!accessToken &&
|
||||
cached?.accessToken &&
|
||||
isAdobeUserAccessToken(cached.accessToken) &&
|
||||
sessionCache.has(fingerprint) &&
|
||||
Date.now() - cached.updatedAt < 30 * 60_000
|
||||
) {
|
||||
accessToken = cached.accessToken;
|
||||
}
|
||||
|
||||
// Cookie blob
|
||||
for (const b of blobs) {
|
||||
const c = extractAdobeCookieHeader(b);
|
||||
if (c) {
|
||||
cookie = c;
|
||||
break;
|
||||
}
|
||||
if (looksLikeAdobeCookieBlob(b)) {
|
||||
cookie = extractAdobeCookieHeader(b) || b;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!cookie && cached?.cookie) cookie = cached.cookie;
|
||||
if (cached?.cookie && cookie) cookie = mergeAdobeCookieHeaders(cached.cookie, cookie);
|
||||
|
||||
// Cookie-only or near-expiry JWT → try IMS exchange (needs real IMS cookies on adobelogin.com)
|
||||
const tokenExpiresAt = accessToken ? estimateAdobeTokenExpiry(accessToken) : 0;
|
||||
const needJwtRefresh =
|
||||
!accessToken ||
|
||||
!pasteHadUserJwt ||
|
||||
(tokenExpiresAt > 0 && tokenExpiresAt - Date.now() < JWT_REFRESH_SKEW_MS);
|
||||
|
||||
if (needJwtRefresh && cookie) {
|
||||
try {
|
||||
const refreshed = await exchangeAdobeCookieForAccessToken(cookie, fetchImpl);
|
||||
if (isAdobeUserAccessToken(refreshed)) {
|
||||
accessToken = refreshed;
|
||||
opts.log?.info?.("ADOBE-FIREFLY", "IMS cookie exchange produced a user JWT");
|
||||
}
|
||||
} catch {
|
||||
// Fall through — pure firefly cookies still yield guest-only; keep existing JWT.
|
||||
}
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
// Last resort: full resolve path (throws guest_token with help text)
|
||||
accessToken = await resolveAdobeAccessToken(opts.credentials, fetchImpl);
|
||||
}
|
||||
|
||||
const arpAge = cached ? Date.now() - cached.updatedAt : Number.POSITIVE_INFINITY;
|
||||
const shouldRotate =
|
||||
Boolean(opts.rotateArp) ||
|
||||
Boolean(opts.forceRefresh) ||
|
||||
arpAge > ARP_MAX_AGE_MS ||
|
||||
!cached?.arpSessionId;
|
||||
|
||||
let arpSessionId = shouldRotate
|
||||
? resolveAdobeArpSessionIdSmart(cookie || joined, { rotate: true })
|
||||
: cached?.arpSessionId || resolveAdobeArpSessionIdSmart(cookie || joined);
|
||||
|
||||
let session: AdobeFireflySession = {
|
||||
accessToken,
|
||||
cookie: cookie || extractAdobeCookieHeader(joined) || "",
|
||||
arpSessionId,
|
||||
tokenExpiresAt: estimateAdobeTokenExpiry(accessToken),
|
||||
updatedAt: Date.now(),
|
||||
fingerprint,
|
||||
source: shouldRotate ? "rebuild" : cached?.source || "paste",
|
||||
};
|
||||
|
||||
// Browser warm-up is OFF by default: headless Forter/Arkose is rejected by colligo (408).
|
||||
// Enable only with ADOBE_FIREFLY_BROWSER_REFRESH=1 (and forceRefresh / missing ARP pieces).
|
||||
const allowBrowser =
|
||||
opts.allowBrowserRefresh === true || process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "1";
|
||||
const needsBrowser =
|
||||
allowBrowser &&
|
||||
Boolean(session.cookie) &&
|
||||
(opts.forceRefresh ||
|
||||
(!canRebuildAdobeArpFromCookies(session.cookie) && !extractAdobeArpSessionId(session.cookie)));
|
||||
|
||||
if (needsBrowser && session.cookie) {
|
||||
const key = fingerprint;
|
||||
let inflight = browserRefreshInFlight.get(key);
|
||||
if (!inflight) {
|
||||
inflight = refreshAdobeSessionViaBrowser(session, opts.log).finally(() => {
|
||||
browserRefreshInFlight.delete(key);
|
||||
});
|
||||
browserRefreshInFlight.set(key, inflight);
|
||||
}
|
||||
const warmed = await inflight;
|
||||
if (warmed) session = warmed;
|
||||
}
|
||||
|
||||
// Final ARP if still empty
|
||||
if (!session.arpSessionId) {
|
||||
session.arpSessionId = resolveAdobeArpSessionIdSmart(session.cookie || joined, {
|
||||
rotate: true,
|
||||
});
|
||||
}
|
||||
|
||||
sessionCache.set(fingerprint, session);
|
||||
saveDiskSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a colligo 408: rotate ARP (and optionally warm browser), return next session.
|
||||
*/
|
||||
export async function rotateAdobeFireflySessionOnError(
|
||||
session: AdobeFireflySession,
|
||||
opts?: {
|
||||
tryBrowser?: boolean;
|
||||
log?: AdobeFireflySessionResolveOpts["log"];
|
||||
}
|
||||
): Promise<AdobeFireflySession> {
|
||||
let next: AdobeFireflySession = {
|
||||
...session,
|
||||
arpSessionId: resolveAdobeArpSessionIdSmart(session.cookie, { rotate: true }),
|
||||
updatedAt: Date.now(),
|
||||
source: "rebuild",
|
||||
};
|
||||
|
||||
if (opts?.tryBrowser && session.cookie && process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0") {
|
||||
const warmed = await refreshAdobeSessionViaBrowser(next, opts.log);
|
||||
if (warmed) next = warmed;
|
||||
}
|
||||
|
||||
// Always mint a unique synthetic ARP if rebuild produced the same string
|
||||
if (next.arpSessionId === session.arpSessionId) {
|
||||
next.arpSessionId = buildAdobeArpSessionId();
|
||||
next.source = "rebuild";
|
||||
}
|
||||
|
||||
sessionCache.set(session.fingerprint, next);
|
||||
saveDiskSession(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Test helper — clear in-memory session cache. */
|
||||
export function __resetAdobeFireflySessionCacheForTests(): void {
|
||||
sessionCache.clear();
|
||||
browserRefreshInFlight.clear();
|
||||
}
|
||||
@@ -190,48 +190,6 @@ test("buildAdobeImagePayload produces nano and gpt-image shapes", () => {
|
||||
assert.equal((gpt.modelSpecificPayload as Record<string, unknown>).size, "auto");
|
||||
assert.equal(gpt.size, undefined);
|
||||
assert.equal(gpt.outputResolution, undefined);
|
||||
|
||||
// Missing / auto quality → maximal detail (5). Explicit low/medium still honored.
|
||||
const gptDefault = buildAdobeImagePayload({
|
||||
prompt: "a dog",
|
||||
aspectRatio: "1:1",
|
||||
outputResolution: "1K",
|
||||
modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image-2"],
|
||||
});
|
||||
assert.equal((gptDefault.generationSettings as Record<string, unknown>).detailLevel, 5);
|
||||
const gptAuto = buildAdobeImagePayload({
|
||||
prompt: "a dog",
|
||||
aspectRatio: "1:1",
|
||||
outputResolution: "1K",
|
||||
modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"],
|
||||
quality: "auto",
|
||||
});
|
||||
assert.equal((gptAuto.generationSettings as Record<string, unknown>).detailLevel, 5);
|
||||
const gptLow = buildAdobeImagePayload({
|
||||
prompt: "a dog",
|
||||
aspectRatio: "1:1",
|
||||
outputResolution: "1K",
|
||||
modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"],
|
||||
quality: "low",
|
||||
});
|
||||
assert.equal((gptLow.generationSettings as Record<string, unknown>).detailLevel, 1);
|
||||
const gptMedium = buildAdobeImagePayload({
|
||||
prompt: "a dog",
|
||||
aspectRatio: "1:1",
|
||||
outputResolution: "1K",
|
||||
modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"],
|
||||
quality: "medium",
|
||||
});
|
||||
assert.equal((gptMedium.generationSettings as Record<string, unknown>).detailLevel, 3);
|
||||
// Firefly UI resolution tiers map onto the same detailLevel scale.
|
||||
const gpt4k = buildAdobeImagePayload({
|
||||
prompt: "a dog",
|
||||
aspectRatio: "1:1",
|
||||
outputResolution: "1K",
|
||||
modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"],
|
||||
quality: "4k",
|
||||
});
|
||||
assert.equal((gpt4k.generationSettings as Record<string, unknown>).detailLevel, 5);
|
||||
});
|
||||
|
||||
test("buildAdobeImagePayload attaches referenceBlobs like live adobe_atach_images capture", () => {
|
||||
@@ -250,7 +208,10 @@ test("buildAdobeImagePayload attaches referenceBlobs like live adobe_atach_image
|
||||
{ id: "2a4f1025-e0dc-4671-a11a-7dfd3c07bd94", usage: "general" },
|
||||
{ id: "84c11d1a-e798-4300-a63e-c06504ca2068", usage: "general" },
|
||||
]);
|
||||
assert.equal((nano.generationMetadata as Record<string, unknown>).module, "text2image");
|
||||
assert.equal(
|
||||
(nano.generationMetadata as Record<string, unknown>).module,
|
||||
"text2image"
|
||||
);
|
||||
|
||||
const gpt = buildAdobeImagePayload({
|
||||
prompt: "edit me",
|
||||
@@ -262,7 +223,10 @@ test("buildAdobeImagePayload attaches referenceBlobs like live adobe_atach_image
|
||||
assert.deepEqual(gpt.referenceBlobs, [
|
||||
{ id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", usage: "subject" },
|
||||
]);
|
||||
assert.equal((gpt.generationMetadata as Record<string, unknown>).module, "image2image");
|
||||
assert.equal(
|
||||
(gpt.generationMetadata as Record<string, unknown>).module,
|
||||
"image2image"
|
||||
);
|
||||
});
|
||||
|
||||
test("extractAdobeSourceImageSources reads Media page image fields", () => {
|
||||
@@ -316,10 +280,10 @@ test("resolveAdobeSourceImageIds uploads data URLs then returns blob ids", async
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
assert.match(String(headers["content-type"] || headers["Content-Type"] || ""), /image\//);
|
||||
assert.ok(init?.body);
|
||||
return new Response(JSON.stringify({ images: [{ id: `blob-${uploadCalls}` }] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({ images: [{ id: `blob-${uploadCalls}` }] }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch ${u}`);
|
||||
};
|
||||
@@ -423,7 +387,8 @@ test("buildAdobeSubmitNonce is sha256(user_id + prompt[:256])", async () => {
|
||||
type: "access_token",
|
||||
client_id: "clio-playground-web",
|
||||
})
|
||||
).toString("base64url");
|
||||
)
|
||||
.toString("base64url");
|
||||
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url");
|
||||
const token = `${header}.${payload}.${"x".repeat(40)}`;
|
||||
// Pad token length for looksLikeAdobeJwt (>=80)
|
||||
@@ -452,11 +417,13 @@ test("buildAdobeSubmitNonce is sha256(user_id + prompt[:256])", async () => {
|
||||
assert.equal(isValidAdobeArpSessionId(arp), true);
|
||||
const decoded = JSON.parse(Buffer.from(arp, "base64").toString("utf8"));
|
||||
assert.ok(decoded.sid);
|
||||
// Live SPA shape (2026-07): sid + ark (Arkose) + ftr with __UDF43-m4_31ck magic
|
||||
// Live SPA shape (2026-07): sid + ark (Arkose) + ftr + bfp + fpjs
|
||||
assert.ok(decoded.ark, "synthetic ARP must include ark field");
|
||||
assert.match(String(decoded.ark), /pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C/);
|
||||
assert.match(String(decoded.ftr), new RegExp(ADOBE_FIREFLY_FTR_MAGIC));
|
||||
assert.match(String(decoded.ftr), /-v2_tt$/);
|
||||
assert.ok(decoded.bfp, "synthetic ARP must include bfp (browser fingerprint)");
|
||||
assert.ok(decoded.fpjs, "synthetic ARP must include fpjs");
|
||||
|
||||
// Headers: deterministic nonce + always ARP (synthetic when none provided)
|
||||
const h = buildAdobeSubmitHeaders(token, { prompt });
|
||||
@@ -482,7 +449,8 @@ test("buildAdobeSubmitNonce is sha256(user_id + prompt[:256])", async () => {
|
||||
});
|
||||
|
||||
test("normalizeAdobePollUrl rewrites firefly-epo jobs/result to BKS", () => {
|
||||
const raw = "https://firefly-epo855232.adobe.io/jobs/result/4ae9fd2a-0864-46dd-9834-cfc16e91faa6";
|
||||
const raw =
|
||||
"https://firefly-epo855232.adobe.io/jobs/result/4ae9fd2a-0864-46dd-9834-cfc16e91faa6";
|
||||
const out = normalizeAdobePollUrl(raw);
|
||||
assert.match(out, /^https:\/\/bks-epo8552\.adobe\.io\/v2\/jobs\/result\/4ae9fd2a/);
|
||||
assert.match(out, /host=firefly-epo855232\.adobe\.io/);
|
||||
@@ -564,9 +532,9 @@ test("fallback catalog has image and video entries from get_models capture", ()
|
||||
|
||||
test("extractAdobeAccountIdFromToken reads user_id claim", () => {
|
||||
// {"user_id":"0EB@AdobeID"} base64url
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token" })
|
||||
).toString("base64url");
|
||||
const payload = Buffer.from(JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token" })).toString(
|
||||
"base64url"
|
||||
);
|
||||
const jwt = `eyJhbGciOiJub25lIn0.${payload}.sig`;
|
||||
assert.equal(extractAdobeAccountIdFromToken(jwt), "0EB@AdobeID");
|
||||
});
|
||||
@@ -751,19 +719,13 @@ test("guest JWT without AdobeID is detected", () => {
|
||||
const emptyPayload = Buffer.from("{}").toString("base64url");
|
||||
const guestJwt = `eyJhbGciOiJub25lIn0.${emptyPayload}.sig`;
|
||||
// Pad to lookLikeAdobeJwt length if needed
|
||||
const longGuest =
|
||||
`eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify({ client_id: "clio-playground-web" })).toString("base64url")}.` +
|
||||
"x".repeat(40);
|
||||
const longGuest = `eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify({ client_id: "clio-playground-web" })).toString("base64url")}.` + "x".repeat(40);
|
||||
assert.equal(isAdobeGuestAccessToken(longGuest), true);
|
||||
const userJwt =
|
||||
`eyJhbGciOiJSUzI1NiJ9.` +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
user_id: "0EB@AdobeID",
|
||||
type: "access_token",
|
||||
client_id: "clio-playground-web",
|
||||
})
|
||||
).toString("base64url") +
|
||||
Buffer.from(JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token", client_id: "clio-playground-web" })).toString(
|
||||
"base64url"
|
||||
) +
|
||||
`.` +
|
||||
"y".repeat(40);
|
||||
assert.equal(isAdobeGuestAccessToken(userJwt), false);
|
||||
@@ -835,18 +797,89 @@ test("extractAdobeArpSessionId recovers JWT+ARP joined by space (PasswordBox man
|
||||
);
|
||||
assert.match(
|
||||
formatAdobeSystemUnderLoadError("image", 2, { hadBrowserArp: true }),
|
||||
/fresh successful generate-async/i
|
||||
/auto-rebuilds x-arp-session-id|Cookie once|Forter\/Arkose/i
|
||||
);
|
||||
});
|
||||
|
||||
test("isAdobeTransientSubmitError detects 408 system under load", () => {
|
||||
assert.equal(
|
||||
isAdobeTransientSubmitError(
|
||||
408,
|
||||
'{"error_code":"timeout_error","message":"system under load"}'
|
||||
),
|
||||
true
|
||||
test("extractAdobeArpSessionId does not pick aux_sid over sherlockToken", async () => {
|
||||
const { extractAdobeArpSessionId, isValidAdobeArpSessionId } = await import(
|
||||
"../../open-sse/services/adobeFireflyClient.ts"
|
||||
);
|
||||
const { ADOBE_FIREFLY_FTR_MAGIC } = await import("../../open-sse/services/adobeFireflyClient.ts");
|
||||
const realArp = Buffer.from(
|
||||
JSON.stringify({
|
||||
sid: "bdf37b8a-117f-467d-a737-7792932d98b4",
|
||||
ark: "10618c58b8d3cd588.3119555905|r=eu-west-1|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C",
|
||||
ftr: `aa_${Date.now()}${ADOBE_FIREFLY_FTR_MAGIC}_x=-1-v2_tt`,
|
||||
}),
|
||||
"utf8"
|
||||
).toString("base64");
|
||||
// Long aux_sid must NOT win ranking (this was the live 408 root cause)
|
||||
const aux = "A" + "x".repeat(780);
|
||||
const cookie = `ff_session_guid=bdf37b8a-117f-467d-a737-7792932d98b4; sherlockToken=${realArp}; aux_sid=${aux}; forterToken=x`;
|
||||
const got = extractAdobeArpSessionId(cookie);
|
||||
assert.equal(got, realArp);
|
||||
assert.equal(isValidAdobeArpSessionId(`aux_sid=${aux}`), false);
|
||||
assert.equal(isValidAdobeArpSessionId(realArp), true);
|
||||
});
|
||||
|
||||
test("rebuild ARP from cookie components (forter+arkose+sid)", async () => {
|
||||
const {
|
||||
buildAdobeArpSessionIdFromCookies,
|
||||
canRebuildAdobeArpFromCookies,
|
||||
mergeAdobeCookieHeaders,
|
||||
resolveAdobeArpSessionIdSmart,
|
||||
serializeAdobeFireflyCredential,
|
||||
normalizeAdobeForterToken,
|
||||
} = await import("../../open-sse/services/adobeFireflySession.ts");
|
||||
const { ADOBE_FIREFLY_FTR_MAGIC } = await import("../../open-sse/services/adobeFireflyClient.ts");
|
||||
|
||||
const ftr = `aab9dc9eb48f4ee1916428649f908f7d_${Date.now()}${ADOBE_FIREFLY_FTR_MAGIC}_x=-1092-v2_tt`;
|
||||
const ark =
|
||||
"87818c58b11662a57.5347274705|r=eu-west-1|meta=3|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C|at=40";
|
||||
const cookie =
|
||||
`ff_session_guid=bdf37b8a-117f-467d-a737-7792932d98b4; arkose=${ark}; ` +
|
||||
`forterToken=${encodeURIComponent(ftr)}; bfp=58ef2899-b1c4-42e4-9625-ae265e1b4994; ` +
|
||||
`fpjs=${encodeURIComponent(JSON.stringify({ requestId: "1.x", visitorId: "v" }))}`;
|
||||
|
||||
assert.equal(canRebuildAdobeArpFromCookies(cookie), true);
|
||||
const arp = buildAdobeArpSessionIdFromCookies(cookie);
|
||||
assert.ok(arp.length > 40);
|
||||
const decoded = JSON.parse(Buffer.from(arp, "base64").toString("utf8"));
|
||||
assert.equal(decoded.sid, "bdf37b8a-117f-467d-a737-7792932d98b4");
|
||||
assert.equal(decoded.ark, ark);
|
||||
assert.ok(String(decoded.ftr).includes(ADOBE_FIREFLY_FTR_MAGIC));
|
||||
assert.equal(decoded.bfp, "58ef2899-b1c4-42e4-9625-ae265e1b4994");
|
||||
assert.ok(decoded.fpjs);
|
||||
|
||||
// forter without _tt suffix gets normalized
|
||||
assert.match(normalizeAdobeForterToken("abc_1__UDF43-m4_31ck_x=-1-v2"), /-v2_tt$/);
|
||||
// localStorage comma form is rejected
|
||||
assert.equal(normalizeAdobeForterToken("aab9dc9eb48f4ee1916428649f908f7d,1784986682306"), "");
|
||||
|
||||
const merged = mergeAdobeCookieHeaders(
|
||||
"ff_session_guid=old; arkose=a1",
|
||||
"arkose=a2; forterToken=newftr"
|
||||
);
|
||||
assert.match(merged, /arkose=a2/);
|
||||
assert.match(merged, /forterToken=newftr/);
|
||||
assert.match(merged, /ff_session_guid=old/);
|
||||
|
||||
// Smart resolve prefers rebuild when cookie pieces present
|
||||
const smart = resolveAdobeArpSessionIdSmart(cookie);
|
||||
assert.equal(smart, arp);
|
||||
|
||||
const ser = serializeAdobeFireflyCredential({
|
||||
accessToken: "eyJ.token.sig",
|
||||
cookie,
|
||||
arpSessionId: arp,
|
||||
});
|
||||
assert.match(ser, /eyJ\.token\.sig/);
|
||||
assert.match(ser, /ff_session_guid=/);
|
||||
});
|
||||
|
||||
test("isAdobeTransientSubmitError detects 408 system under load", () => {
|
||||
assert.equal(isAdobeTransientSubmitError(408, '{"error_code":"timeout_error","message":"system under load"}'), true);
|
||||
assert.equal(isAdobeTransientSubmitError(429, "rate"), true);
|
||||
assert.equal(isAdobeTransientSubmitError(400, "bad request"), false);
|
||||
assert.ok(generateAdobeNonce().length === 64);
|
||||
@@ -894,7 +927,11 @@ test("image submit retries on 408 then succeeds", async () => {
|
||||
if (submits < 3) {
|
||||
return jsonResponse(408, { error_code: "timeout_error", message: "system under load" });
|
||||
}
|
||||
return jsonResponse(200, { links: { result: { href: "https://poll.example/job/r1" } } }, {});
|
||||
return jsonResponse(
|
||||
200,
|
||||
{ links: { result: { href: "https://poll.example/job/r1" } } },
|
||||
{}
|
||||
);
|
||||
}
|
||||
if (u.includes("poll.example")) {
|
||||
return jsonResponse(200, {
|
||||
@@ -916,14 +953,15 @@ test("image submit retries on 408 then succeeds", async () => {
|
||||
});
|
||||
|
||||
test("adobeFireflyGenerateImage cookie path exchanges IMS token first", async () => {
|
||||
const { __resetAdobeFireflySessionCacheForTests } = await import(
|
||||
"../../open-sse/services/adobeFireflySession.ts"
|
||||
);
|
||||
__resetAdobeFireflySessionCacheForTests();
|
||||
|
||||
const userTok =
|
||||
`eyJhbGciOiJSUzI1NiJ9.` +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
user_id: "0EB@AdobeID",
|
||||
type: "access_token",
|
||||
client_id: "clio-playground-web",
|
||||
})
|
||||
JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token", client_id: "clio-playground-web" })
|
||||
).toString("base64url") +
|
||||
`.` +
|
||||
"s".repeat(40);
|
||||
@@ -945,7 +983,11 @@ test("adobeFireflyGenerateImage cookie path exchanges IMS token first", async ()
|
||||
? (init.headers as Record<string, string>).Authorization
|
||||
: auth;
|
||||
assert.equal(headerAuth, `Bearer ${userTok}`);
|
||||
return jsonResponse(200, {}, { "x-override-status-link": "https://poll.example/job/c1" });
|
||||
return jsonResponse(
|
||||
200,
|
||||
{},
|
||||
{ "x-override-status-link": "https://poll.example/job/c1" }
|
||||
);
|
||||
}
|
||||
if (String(url).includes("poll.example")) {
|
||||
return jsonResponse(200, {
|
||||
|
||||
Reference in New Issue
Block a user