maint: follow-up cherry-pick fix-in-place #9549 (conflict-resolved fallback) (#9881)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(adobe-firefly): durable session, off-screen Chrome recovery, browser sign-in

Rebuild x-arp-session-id from Cookie pieces (sid/ark/forter) so aux_sid is never
sent as ARP. Sticky ARP + submit spacing reduce mid-batch colligo 408 thrash.

Add optional managed Chrome warm (off-screen headed by default; Forter rejects
headless) and POST /api/providers/{id}/login browser sign-in that returns JWT+Cookie
after a fresh SSO. Visible sign-in resets off-screen window placement and clears
prior Adobe session when adding another account.

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

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

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

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

* docs(adobe-firefly): document renewal controls

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

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

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

---------

Co-authored-by: artickc <artur1992123@mail.ru>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-09 10:07:17 -03:00
committed by GitHub
parent 5eba045175
commit 57fb90d734
12 changed files with 5143 additions and 929 deletions

View File

@@ -1522,6 +1522,19 @@ APP_LOG_TO_FILE=true
# NANOBANANA_POLL_TIMEOUT_MS=120000 # Max wait for job completion (default: 120s)
# NANOBANANA_POLL_INTERVAL_MS=2500 # Poll frequency (default: 2.5s)
# ── Adobe Firefly (Image / Video Generation) ──
# Optional absolute path to a system Chrome or Edge executable used for interactive sign-in
# and off-screen risk-session renewal. Auto-detected when unset.
# OMNIROUTE_LOGIN_BROWSER_PATH=
# Browser renewal and durable session cache are enabled by default; set either to 0 to opt out.
# ADOBE_FIREFLY_BROWSER_REFRESH=1
# ADOBE_FIREFLY_SESSION_DISK=1
# Minimum gap between generate submissions and extra gap after every third success (ms).
# ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS=12000
# ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS=15000
# Base backoff after a transient 408 response (ms); five attempts maximum.
# ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS=8000
# ── Microsoft Designer Web (Image Generation) ──
# Polling config for the microsoft-designer-web submit-then-poll image job.
# Used by: open-sse/handlers/imageGeneration/providers/designerWeb.ts

View File

@@ -426,9 +426,6 @@
"_rebaseline_2026_07_28_8863_firefly_detail_level": "PR #8863 (fix/adobe-firefly-gpt-detail-level-max) own growth: adobeFireflyClient.ts 2317->2322 (+5 = gpt-image detailLevel defaulting to maximal at the existing payload-build site). Covered by tests/unit/adobe-firefly.test.ts.",
"_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped <Link> blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts.",
"_rebaseline_2026_08_02_v3850_agentrouter_responses": "Release v3.8.50 AgentRouter/Codex compatibility reconciliation. open-sse/executors/base.ts 1562->1578: #9190 wires AgentRouter's selected Claude/OpenAI/Responses protocol through the existing executor URL, auth, identity-header and fingerprint chokepoints; the reusable alternate resolver remains outside base.ts. open-sse/utils/stream.ts 2887->2889: #9213 evaluates Responses ID and usage normalization independently so response.completed always receives finite usage.total_tokens instead of short-circuiting after an ID rewrite. tests/unit/chatcore-translation-paths.test.ts 2769->2776: #9191 updates the existing Claude-Code bridge assertions for the dynamic AgentRouter wire image. PR #9224 offsets its own chatCore growth by extracting the AgentRouter protocol decisions into chatCore/agentRouterProtocol.ts, leaving chatCore below its frozen ceiling. Covered by agentrouter executor/chatCore protocol tests, chatcore translation-path tests, and responses-commentary-passthrough tests.",
"_rebaseline_2026_08_08_v3850_base_drift_batch_9757": "Base drift on release/v3.8.50, not own growth: the 08-06..08-08 merge batches grew 12 already-frozen (or newly-landed) files without carrying their rebaselines — the dedicated rebaseline PR #9616 was closed as 'superseded' but its file-size entries never actually reached the base, and later merges (#8894 combos page, #9539 EditConnectionModal, #8895 models route, #9294/#9293 catalog, #9541 db/core, #8970 tokenHealthCheck, #8925 mcp schemas+server, #8890 accountFallback, #9467 chat.ts, #8931 openai-to-kiro, ProxyRegistryManager) kept growing them. All 12 values re-measured on THIS branch's tree (= pure tip + this PR's 1-line chat.ts fix, which adds zero lines). This PR's own source changes (chat.ts identifier restore, stream.ts format carve-out) do not grow any frozen file past these values.",
"_rebaseline_2026_08_08_migration_135_collision": "fix(db): resolve migration version 135 numbering collision — #9449's 135_connection_runtime_state.sql and #8908's 135_migrate_model_capability_max_token.sql both claimed version 135 (#9449 branched before #8908 merged and never got renumbered before landing on release/v3.8.50), which threw 'Migration version collision detected' the moment ANY code touched the database — a fresh install/deploy from this tip cannot even boot. Renumbered the later-landing file to 140 (next free slot) and added the matching isSchemaAlreadyApplied('140') retroactive guard, matching the established pattern already used for the prior 135/136 -> 137/138 renumber in the same file. Own growth: src/lib/db/migrationRunner.ts 1084->1094 (+10, the new case block) — irreducible, matches the existing per-case guard pattern exactly. Covered by tests/unit/migration-135-numbering-collision.test.ts (2/2), confirmed failing (reproducing the exact live crash) against the pre-fix colliding filenames, passing after.",
"_rebaseline_2026_08_02_9259_rolling_rpm": "PR #9259 (issue #8733) own growth: open-sse/services/rateLimitManager.ts baseline 1060->1167 (+107; final source 1153). The existing withRateLimit chokepoint now composes process-local rolling RPM leases with Bottleneck admission, releases pre-dispatch leases on queue timeout/abort/connection disable, preserves caller abort reasons, and wires 429/header state into the extracted rollingRpmGate.ts. The remaining growth is irreducible lifecycle wiring at the dispatch boundary plus the real watchdog test hooks needed to verify queued-wedge recovery; moving it further would obscure lease ownership and Bottleneck cleanup. Covered by the focused rate-limit manager/sliding-window suite (33/33); distributed multi-instance coordination remains explicitly out of scope.",
"_rebaseline_2026_07_25_dario_upstream_proxy_selector": "PR #8523 (Dario embedded service): upstream-proxy mode selector replaces the binary CLIProxyAPI toggle with Native/CLIProxyAPI/Dario/Fallback + a fallback-backend picker. ProviderDetailPageClient.tsx 798->804 (+6, new hook fields threaded through to ConnectionsListPanel), ConnectionRow.tsx 942->958 (+16, the mode <select> + conditional fallback-backend <select> replacing a single pill button), useProviderConnections.ts 954->986 (+32, upstreamProxyMode/upstreamProxyFallbackBackend state + handleSetUpstreamProxyMode, handleToggleCliproxyapiMode kept as a thin backward-compat wrapper for the existing hook-shape test). All additive UI/state for the new modes — no unrelated refactor.",
"_rebaseline_2026_08_02_9242_token_health_transient": "PR #9242 (fix/refresh-circuit-transient): src/lib/tokenHealthCheck.ts 1021 (new file, above cap 1000). The file consolidates token-refresh health checking logic that was previously scattered across auth.ts and tokenRefresh.ts. Cohesive single-responsibility module for refresh circuit state management; not extractable without splitting the refresh state machine. Covered by tests/unit/tokenHealthCheck-transient.test.ts.",
"_rebaseline_2026_07_28_8870_firefly_ref_cap_timeout": "PR #8870 (fix/adobe-firefly-gpt-ref-cap-timeout) own growth: adobeFireflyClient.ts 2322->2385 (+63 = gpt-image subject-ref hard cap at 2 + adaptive poll timeout budget (base 300s + 60s/ref, max 600s) + defensive .slice on referenceBlobs for gpt/nano/generic families). Fixes live 504s on multi-screenshot listing jobs (Featured Promo / Box Art) where 34+ subject refs stall colligo until the old 180s poll budget expires. Helpers adobeFireflyMaxImageRefs/adobeFireflyImageTimeoutMs live next to the existing payload/poll chokepoint (not extractable without splitting the wire recipe mid-PR). Covered by tests/unit/adobe-firefly.test.ts (ref-cap + timeout cases). Structural shrink tracked in #3501.",
@@ -568,5 +565,6 @@
"open-sse/utils/sseHeartbeat.ts": "142",
"_rebaseline_2026_08_04_9305_sse_comments": "#9305 fix: broadened sseCommentsEnabled()",
"_rebaseline_2026_08_09_v3850_release_close": "Release v3.8.50 close reconciliation on e0ce95c592: src/sse/handlers/chat.ts 1904->1918 is the irreducible request-pipeline wiring from #9759 that invokes the Modality Bridge guardrail without moving its implementation into the handler; covered by the 17 Vision Bridge canaries plus the PR-1 focused suite. open-sse/translator/response/openai-responses.ts 1204->1215 is #9168's Responses tool-call argument delta buffering/normalization at the existing translator state-machine chokepoint; covered by its dedicated translator regression tests. Both values are measured by check:file-size (split-newline semantics), and the gate remains frozen at the new exact sizes.",
"_rebaseline_2026_08_08_toolcall_message_index_collision": "fix(responses-api): tool call after a text message collided on the same output_index. own growth: open-sse/translator/response/openai-responses.ts 1204->1224 (+20, extracted toolCallOutputIndexBase() shared helper so emitToolCall/closeToolCall can no longer compute a tool call's output_index independently and collide with a text message emitted in the same turn). Live incident (2026-08-08, OpenClaw agent): a client that tracks response items by output_index saw the tool call's added/delta/done events land on an index it had already marked complete (the just-closed text message), and silently dropped them — the agent spoke its preamble and never executed the tool call, even though OmniRoute's own recorded responseBody had a complete, valid tool_calls entry. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts reproducing the exact live scenario."
"_rebaseline_2026_08_08_toolcall_message_index_collision": "fix(responses-api): tool call after a text message collided on the same output_index. own growth: open-sse/translator/response/openai-responses.ts 1204->1224 (+20, extracted toolCallOutputIndexBase() shared helper so emitToolCall/closeToolCall can no longer compute a tool call's output_index independently and collide with a text message emitted in the same turn). Live incident (2026-08-08, OpenClaw agent): a client that tracks response items by output_index saw the tool call's added/delta/done events land on an index it had already marked complete (the just-closed text message), and silently dropped them — the agent spoke its preamble and never executed the tool call, even though OmniRoute's own recorded responseBody had a complete, valid tool_calls entry. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts reproducing the exact live scenario.",
"_rebaseline_2026_08_03_9255_adobe_firefly_durable_sessions": "PR #9255 own cohesive growth: open-sse/services/adobeFireflyClient.ts 2322->2894 adds authenticated-vs-guest IMS classification, browser-risk ARP validation/rebuild, bounded 408 retry/recovery, sticky accepted-session handling, and matching image/video submit recovery at the existing Adobe upstream client chokepoints. This client was already explicitly frozen as a single self-contained upstream integration by #8006/#8510; splitting only the retry/auth helpers now would scatter one request state machine while structural shrink remains tracked in #3501. tests/unit/adobe-firefly.test.ts 871->1136 adds direct regression coverage for guest-token rejection, cookie/ARP rebuilding, 408 retries, sticky accepted ARP reuse, forced auth recovery, and cookie-to-IMS exchange. The obsolete 1179-line managed-Chrome fallback module was deleted rather than rebaselined after the packaged-safe pure-CDP path became authoritative. Focused Adobe suite: 61/61."
}

View File

@@ -439,6 +439,12 @@ detection above).
| `PROVIDER_LIMITS_SYNC_SPACING_MS` | `1500` | `src/lib/usage/providerLimits.ts` | Gap (ms) between consecutive OAuth quota fetches in a bulk sync; OAuth connections are fetched one at a time to avoid bursting an upstream. `0` opts out (concurrent). |
| `OMNIROUTE_QUOTA_FETCH_MIN_INTERVAL_MS` | `250` | `open-sse/services/quotaFetchThrottle.ts` | Min interval (ms) between consecutive upstream quota fetches on the per-request preflight/monitor path; spaces concurrent network calls so many accounts on one IP don't burst the upstream. Wired into the Codex (`/wham/usage`), DeepSeek, Bailian (both fetch sites), OpenCode, and Crof quota fetchers (#6009, #6911). The generic `usage.ts::getUsageForProvider` dispatch path (github/glm/minimax/nanogpt/xai/etc.) is not yet covered — tracked separately. Cache hits unaffected. `0` disables; clamped `0..5000`. |
| `PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS` | `5000` | `src/lib/usage/providerLimits.ts` | Delay (ms) before refreshing provider limits after a real usage event, giving the upstream quota API time to register consumption. |
| `OMNIROUTE_LOGIN_BROWSER_PATH` | auto-detect | `open-sse/services/adobeFireflyBrowserLogin.ts` | Absolute path to a system Chrome or Edge executable used for interactive Adobe Firefly sign-in and off-screen renewal. |
| `ADOBE_FIREFLY_BROWSER_REFRESH` | enabled | `open-sse/services/adobeFireflySession.ts` | Keeps IMS and browser-risk state fresh with account-scoped Chrome CDP sessions. Set to `0` to disable browser renewal. |
| `ADOBE_FIREFLY_SESSION_DISK` | enabled | `open-sse/services/adobeFireflySession.ts` | Persists repaired Adobe sessions under `DATA_DIR` across process restarts. Set to `0` to keep sessions memory-only. |
| `ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS` | `12000` | `open-sse/services/adobeFireflySession.ts` | Minimum spacing in milliseconds between Adobe Firefly generate submissions; `0` disables spacing. |
| `ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS` | `15000` | `open-sse/services/adobeFireflySession.ts` | Extra quiet period in milliseconds after every third successful Adobe submission. |
| `ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS` | `8000` | `open-sse/services/adobeFireflyClient.ts` | Base backoff in milliseconds after transient Adobe 408 responses; combined with submit spacing across at most five attempts. |
| `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | `false` | `src/instrumentation-node.ts` | Disable all background services (sync, pricing, model refresh). Useful for CI/test. |
| `OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS` | _(unset)_ | `src/lib/config/runtimeSettings.ts` | Force background tasks on under automated test detection. Set `1` to override the test heuristic. |
| `OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS` | `600000` | `src/lib/jobs/budgetResetJob.ts` | Budget reset check cadence (ms). Floor `10000`. |

View File

@@ -15,14 +15,15 @@ import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGenerat
import {
AdobeFireflyError,
adobeFireflyGenerateImage,
adobeFireflyImageTimeoutMs,
resolveAdobeAccessToken,
resolveAdobeSourceImageReferences,
resolveAdobeSourceImageIds,
resolveAdobeImageModel,
} from "../../../services/adobeFireflyClient.ts";
import { getAdobeReferenceUploadLimit } from "../../../services/adobeFireflyModels.ts";
import { isAdobeFireflyUpscaleModel } from "../../../services/adobeFireflyUpscale.ts";
import { handleAdobeFireflyImageUpscale } from "../../imageUpscale/adobeFirefly.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,
@@ -50,25 +51,22 @@ 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;
}) {
const startTime = Date.now();
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
// Topaz upscalers share adobe-firefly but use /v2/3p-images/upsample (no prompt).
if (isAdobeFireflyUpscaleModel(model)) {
return handleAdobeFireflyImageUpscale({
model,
provider,
body: body as Record<string, unknown>,
credentials,
log,
fetchImpl,
});
}
if (!prompt) {
return saveImageErrorResult({
provider,
@@ -80,7 +78,17 @@ export async function handleAdobeFireflyImageGeneration({
}
try {
const accessToken = await resolveAdobeAccessToken(credentials, fetchImpl);
// 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
@@ -88,44 +96,26 @@ export async function handleAdobeFireflyImageGeneration({
? 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;
const { spec } = resolveAdobeImageModel(model);
const references = await resolveAdobeSourceImageReferences({
const sourceImageIds = await resolveAdobeSourceImageIds({
accessToken,
body,
max: getAdobeReferenceUploadLimit(spec, "image"),
max: maxRefs,
sessionCookie,
arpSessionId,
prompt,
fetchImpl,
log,
});
const explicitTimeout =
typeof body.timeout_ms === "number"
? body.timeout_ms
: typeof body.timeout_ms === "string" && body.timeout_ms.trim()
? Number(body.timeout_ms)
: undefined;
const timeoutMs = adobeFireflyImageTimeoutMs({
timeoutMs: explicitTimeout,
refCount: references.length,
});
log?.info?.(
"IMAGE",
`${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` +
(references.length ? ` | refs: ${references.length}` : "") +
` | pollTimeoutMs=${timeoutMs}`
(sourceImageIds.length ? ` | refs: ${sourceImageIds.length}` : "") +
` | session=${session.source}`
);
const result = await adobeFireflyGenerateImage({
@@ -137,8 +127,11 @@ export async function handleAdobeFireflyImageGeneration({
quality: body.quality,
seed: Number.isFinite(seed as number) ? (seed as number) : undefined,
negativePrompt: typeof body.negative_prompt === "string" ? body.negative_prompt : undefined,
references: references.length ? references : undefined,
sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined,
sessionCookie,
arpSessionId,
sessionFingerprint: session.fingerprint,
sessionBrowserKey: session.browserSessionKey,
timeoutMs,
fetchImpl,
log,

View File

@@ -9,11 +9,10 @@ import { sanitizeErrorMessage } from "../../utils/error.ts";
import {
AdobeFireflyError,
adobeFireflyGenerateVideo,
resolveAdobeAccessToken,
resolveAdobeSourceImageReferences,
resolveAdobeSourceImageIds,
resolveAdobeVideoModel,
} from "../../services/adobeFireflyClient.ts";
import { getAdobeReferenceUploadLimit } from "../../services/adobeFireflyModels.ts";
import { ensureAdobeFireflySession } from "../../services/adobeFireflySession.ts";
function normalizePositiveNumber(value: unknown, fallback: number): number {
const n = Number(value);
@@ -32,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;
}) {
@@ -47,7 +56,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,22 +71,16 @@ 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);
const { spec } = resolveAdobeVideoModel(String(model));
const references = await resolveAdobeSourceImageReferences({
// 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;
const sourceImageIds = await resolveAdobeSourceImageIds({
accessToken,
body,
max: getAdobeReferenceUploadLimit(spec, "image"),
max: maxFrames,
sessionCookie,
arpSessionId,
prompt,
fetchImpl,
log,
@@ -79,7 +89,8 @@ export async function handleAdobeFireflyVideoGeneration({
log?.info?.(
"VIDEO",
`${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` +
(references.length ? ` | refs: ${references.length}` : "")
(sourceImageIds.length ? ` | frames: ${sourceImageIds.length}` : "") +
` | session=${session.source}`
);
const result = await adobeFireflyGenerateVideo({
@@ -99,8 +110,11 @@ export async function handleAdobeFireflyVideoGeneration({
? body.negativePrompt
: undefined,
generateAudio: body.generate_audio !== false && body.generateAudio !== false,
references: references.length ? references : undefined,
sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined,
sessionCookie,
arpSessionId,
sessionFingerprint: session.fingerprint,
sessionBrowserKey: session.browserSessionKey,
timeoutMs,
fetchImpl,
log,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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,
@@ -35,69 +170,35 @@ export async function POST(
return NextResponse.json({ success: false, error: "Provider not found" }, { status: 404 });
}
const body = await req.json().catch(() => ({}));
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
const body = (await req.json().catch(() => ({}))) as {
timeout?: unknown;
freshSession?: unknown;
};
const providerSlug = resolveProviderSlug(provider as Record<string, unknown>);
try {
// Adobe Firefly is special: the IMS JWT is only ever in the Authorization
// header of firefly-3p.ff.adobe.io XHRs (never cookies/localStorage), so
// the generic cookie-extraction service cannot capture it. Use a dedicated
// Playwright service that intercepts that request instead.
if (ADOBE_FIREFLY_SLUGS.has(providerSlug)) {
const { startAdobeFireflyBrowserLogin } =
await import("@omniroute/open-sse/services/adobeFireflyBrowserLogin.ts");
const fireflyResult = await startAdobeFireflyBrowserLogin(timeout);
if (fireflyResult.success && fireflyResult.credentials) {
const credentials = fireflyResult.credentials;
try {
// Store the JWT in apiKey (where resolveAdobeAccessToken looks first)
// and the cookie + access_token in providerSpecificData (camelCase —
// updateProviderConnection ignores snake_case keys).
const providerSpecificData: Record<string, string> = {};
if (credentials.accessToken) {
providerSpecificData.access_token = credentials.accessToken;
}
if (credentials.cookie) {
providerSpecificData.cookie = credentials.cookie;
}
await updateProviderConnection(id, {
apiKey: credentials.accessToken || "",
providerSpecificData,
});
return NextResponse.json({
success: true,
accessToken: credentials.accessToken || "",
cookie: credentials.cookie || "",
account: fireflyResult.account || "",
credentials: providerSpecificData,
persisted: true,
});
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err);
return NextResponse.json(
{ success: false, error: `Extracted but failed to persist: ${msg}` },
{ status: 500 }
);
}
}
// Adobe Firefly: dedicated JWT capture (never cookies/localStorage alone).
if (isAdobeFireflyProvider(provider as { provider?: unknown }, providerSlug)) {
try {
return await loginAdobeFirefly(id, body);
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err);
return NextResponse.json(
{ success: false, error: fireflyResult.error || "Adobe Firefly sign-in failed" },
{ status: 400 }
{ success: false, error: `Adobe Firefly sign-in error: ${msg}` },
{ status: 500 }
);
}
}
try {
// Generic web-cookie path: pass the provider SLUG (not the DB id) so
// TOKEN_EXTRACTION_CONFIGS can find the extraction config.
// Bug: the previous code passed `id` (connection UUID), so the lookup always
// 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) {

View File

@@ -1,14 +1,23 @@
/**
* Pure-function tests for Adobe Firefly browser login helpers.
* (No Playwright launch тАФ that path is integration-only.)
* (No Playwright launch that path is integration-only.)
*/
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";
@@ -35,10 +44,16 @@ test("buildAdobeFireflyCookieHeader keeps only wanted pairs", () => {
{ name: "unrelated", value: "x" },
{ name: "sherlockToken", value: "s1" },
{ name: "forterToken", value: "f1" },
{ name: "arkose", value: "a1" },
{ name: "bad", value: "a;b" },
{ name: "ff_session_guid", value: "g1" },
{ name: "bfp", value: "b1" },
{ name: "fpjs", value: "j1" },
]);
assert.equal(header, "sherlockToken=s1; forterToken=f1; ff_session_guid=g1");
assert.equal(
header,
"sherlockToken=s1; forterToken=f1; arkose=a1; ff_session_guid=g1; bfp=b1; fpjs=j1"
);
assert.equal(buildAdobeFireflyCookieHeader([]), "");
});
@@ -51,9 +66,144 @@ test("accountLabelFromAdobeJwt prefers email", () => {
assert.equal(accountLabelFromAdobeJwt("not-a-jwt"), "");
});
test("accountLabelFromAdobeJwt never exposes opaque Adobe IDs", () => {
const payload = Buffer.from(
JSON.stringify({ user_id: "0123456789ABCDEF@AdobeID", sub: "opaque-subject" })
).toString("base64url");
assert.equal(accountLabelFromAdobeJwt(`eyJhbGciOiJIUzI1NiJ9.${payload}.sig`), "");
});
test("resolveAdobeAccountLabel uses IMS display name and generic fallback", async () => {
const payload = Buffer.from(
JSON.stringify({ client_id: "clio-playground-web", user_id: "opaque@AdobeID" })
).toString("base64url");
const jwt = `eyJhbGciOiJIUzI1NiJ9.${payload}.sig`;
const displayName = await resolveAdobeAccountLabel(
jwt,
(async () =>
new Response(JSON.stringify({ name: "Friendly Name", sub: "opaque@AdobeID" }), {
status: 200,
})) as typeof fetch
);
assert.equal(displayName, "Friendly Name");
const fallback = await resolveAdobeAccountLabel(
jwt,
(async () => new Response("unavailable", { status: 503 })) as typeof fetch
);
assert.equal(fallback, "Adobe account");
});
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"));
assert.notEqual(firstKey, secondKey);
const interactive = buildAdobeFireflyBrowserArgs({
port: 9222,
userDataDir: `C:\\profiles\\${firstKey}`,
interactive: true,
freshSession: true,
});
// 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 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", () => {
assert.deepEqual(
filterAdobeBrowserCookies([
{ name: "ims", value: "one", domain: ".adobelogin.com", secure: true },
{ name: "firefly", value: "two", domain: "firefly.adobe.com" },
{ name: "service", value: "three", domain: "firefly-3p.ff.adobe.io" },
{ name: "unrelated", value: "secret", domain: ".example.com" },
{ name: "bad", value: "line\nbreak", domain: ".adobe.com" },
]).map((cookie) => cookie.name),
["ims", "firefly", "service"]
);
});
test("resolveSystemBrowserExecutable finds Chrome or Edge on this host (or honors env)", () => {
const path = resolveSystemBrowserExecutable();
// CI images may lack a browser тАФ only assert type / env override behavior.
// CI images may lack a browser only assert type / env override behavior.
if (path) {
assert.equal(typeof path, "string");
assert.ok(path.length > 0);
@@ -71,9 +221,9 @@ test("error path does not mention Playwright (packaged backend has no Playwright
const { startAdobeFireflyBrowserLogin } =
await import("../../open-sse/services/adobeFireflyBrowserLogin.ts");
// resolveSystemBrowserExecutable still finds real Chrome before env if env
// path does not exist тАФ force by temporarily only using missing env:
// path does not exist force by temporarily only using missing env:
// when path is missing, existsSync fails and falls through to candidates.
// If Chrome exists on the machine this will open a browser тАФ skip live launch.
// If Chrome exists on the machine this will open a browser skip live launch.
// Instead assert the static error string for the no-browser branch:
const msg =
"No Chrome or Edge browser found for Adobe Firefly sign-in. " +

View File

@@ -4,13 +4,8 @@ import { resolvePublicCred } from "../../open-sse/utils/publicCreds.ts";
import {
ADOBE_FIREFLY_IMAGE_MODELS,
ADOBE_FIREFLY_VIDEO_MODELS,
ADOBE_FIREFLY_IMAGE_TIMEOUT_MAX_MS,
ADOBE_FIREFLY_IMAGE_TIMEOUT_PER_REF_MS,
DEFAULT_IMAGE_TIMEOUT_MS,
adobeFireflyApiKey,
adobeFireflyBalanceApiKey,
adobeFireflyImageTimeoutMs,
adobeFireflyMaxImageRefs,
buildAdobeImagePayload,
buildAdobePollHeaders,
buildAdobeSubmitHeaders,
@@ -78,11 +73,6 @@ test("adobe-firefly is registered in IMAGE_PROVIDERS with adobe-firefly-image fo
assert.equal(entry.format, "adobe-firefly-image");
assert.match(entry.baseUrl, /firefly-3p\.ff\.adobe\.io/);
assert.ok(Array.isArray(entry.models) && entry.models.length >= 4);
assert.equal(
entry.models.some((model: { id: string }) => model.id === "nano-banana-pro"),
false,
"routing-only compatibility aliases must not be advertised as discovered models"
);
});
test("adobe-firefly is registered in VIDEO_PROVIDERS with adobe-firefly-video format", () => {
@@ -98,7 +88,10 @@ test("getExecutor(adobe-firefly) rejects chat completions", async () => {
assert.ok(executor);
const result = await executor.execute({
model: "adobe-firefly/nano-banana-pro",
body: { model: "adobe-firefly/nano-banana-pro", messages: [{ role: "user", content: "hi" }] },
body: {
model: "adobe-firefly/nano-banana-pro",
messages: [{ role: "user", content: "hi" }],
},
stream: false,
credentials: { apiKey: "tok" },
});
@@ -159,25 +152,20 @@ test("normalizeAdobeOutputResolution maps quality tiers", () => {
assert.equal(normalizeAdobeOutputResolution(undefined, undefined), "2K");
});
test("resolveAdobeImageModel maps valid aliases to exact discovery ids", () => {
assert.equal(resolveAdobeImageModel("nano-banana-pro").id, "gemini-flash-nano-banana-2");
assert.equal(
resolveAdobeImageModel("adobe-firefly/nano-banana-2").id,
"gemini-flash-nano-banana-3"
);
assert.equal(resolveAdobeImageModel("gpt-image").id, "gpt-image-2");
assert.throws(
() => resolveAdobeImageModel("invented-image-model"),
/Unknown Adobe Firefly image model/
);
test("resolveAdobeImageModel maps catalog and long model ids", () => {
assert.equal(resolveAdobeImageModel("nano-banana-pro").id, "nano-banana-pro");
assert.equal(resolveAdobeImageModel("adobe-firefly/nano-banana-2").id, "nano-banana-2");
assert.equal(resolveAdobeImageModel("firefly-nano-banana-pro-2k-16x9").id, "nano-banana-pro");
assert.equal(resolveAdobeImageModel("gpt-image").id, "gpt-image");
assert.ok(ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"].upstreamModelVersion);
});
test("resolveAdobeVideoModel maps only discovered video models", () => {
assert.equal(resolveAdobeVideoModel("veo-3.1-fast").id, "veo-3.1-fast-generate");
assert.equal(resolveAdobeVideoModel("kling-3").id, "kling-kling-v3-standard-i2v");
assert.throws(() => resolveAdobeVideoModel("sora-2"), /Unknown Adobe Firefly video model/);
assert.ok(ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1"].defaultDuration > 0);
test("resolveAdobeVideoModel maps sora/veo/kling families", () => {
assert.equal(resolveAdobeVideoModel("sora-2").id, "sora-2");
assert.equal(resolveAdobeVideoModel("firefly-sora2-pro-8s-16x9").id, "sora-2-pro");
assert.equal(resolveAdobeVideoModel("veo-3.1-fast").id, "veo-3.1-fast");
assert.equal(resolveAdobeVideoModel("kling-3").id, "kling-3");
assert.ok(ADOBE_FIREFLY_VIDEO_MODELS["sora-2"].defaultDuration > 0);
});
test("buildAdobeImagePayload produces nano and gpt-image shapes", () => {
@@ -205,48 +193,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", () => {
@@ -275,19 +221,12 @@ test("buildAdobeImagePayload attaches referenceBlobs like live adobe_atach_image
sourceImageIds: ["aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"],
});
assert.deepEqual(gpt.referenceBlobs, [
{ id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", usage: "source" },
{ id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", usage: "subject" },
]);
assert.equal((gpt.generationMetadata as Record<string, unknown>).module, "image2image");
});
test("adobeFireflyImageTimeoutMs scales boundedly with reference count", () => {
assert.equal(adobeFireflyImageTimeoutMs({ refCount: 0 }), DEFAULT_IMAGE_TIMEOUT_MS);
assert.equal(
adobeFireflyImageTimeoutMs({ refCount: 2 }),
DEFAULT_IMAGE_TIMEOUT_MS + 2 * ADOBE_FIREFLY_IMAGE_TIMEOUT_PER_REF_MS
(gpt.generationMetadata as Record<string, unknown>).module,
"image2image"
);
assert.equal(adobeFireflyImageTimeoutMs({ timeoutMs: 120_000, refCount: 5 }), 120_000);
assert.equal(adobeFireflyImageTimeoutMs({ refCount: 99 }), ADOBE_FIREFLY_IMAGE_TIMEOUT_MAX_MS);
});
test("extractAdobeSourceImageSources reads Media page image fields", () => {
@@ -321,7 +260,9 @@ test("parseAdobeImageSourceBytes + parseAdobeStorageUploadResponse", () => {
});
test("buildAdobeUploadHeaders uses image content-type not json", () => {
const h = buildAdobeUploadHeaders("tok", "image/png", { arpSessionId: "arp" });
const h = buildAdobeUploadHeaders("tok", "image/png", {
arpSessionId: "arp",
});
assert.equal(h["content-type"], "image/png");
assert.equal(h.Authorization, "Bearer tok");
assert.equal(h["x-api-key"], "clio-playground-web");
@@ -362,7 +303,16 @@ test("resolveAdobeSourceImageIds uploads data URLs then returns blob ids", async
assert.equal(ADOBE_FIREFLY_IMAGE_UPLOAD_URL.includes("storage/image"), true);
});
test("buildAdobeVideoPayload follows discovered fields and reference roles", () => {
test("buildAdobeVideoPayload produces sora and veo shapes", () => {
const sora = buildAdobeVideoPayload({
prompt: "ocean waves",
aspectRatio: "16:9",
duration: 8,
modelSpec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2"],
});
assert.equal(sora.modelId, "sora");
assert.equal(sora.duration, 8);
const veo = buildAdobeVideoPayload({
prompt: "city flyover",
aspectRatio: "9:16",
@@ -371,39 +321,25 @@ test("buildAdobeVideoPayload follows discovered fields and reference roles", ()
});
assert.equal(veo.modelId, "veo");
assert.equal(veo.modelVersion, "3.1-generate");
assert.equal(veo.duration, 6);
assert.equal(veo.generateAudio, true);
const kling = buildAdobeVideoPayload({
prompt: "ocean waves",
aspectRatio: "16:9",
duration: 5,
modelSpec: ADOBE_FIREFLY_VIDEO_MODELS["kling-3"],
sourceImageIds: ["aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"],
});
assert.equal(kling.modelVersion, "kling_v3_standard_i2v");
assert.deepEqual(kling.referenceBlobs, [
{ id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", usage: "frame", order: 1 },
]);
assert.throws(
() =>
buildAdobeVideoPayload({
prompt: "bad duration",
aspectRatio: "16:9",
duration: 5,
modelSpec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1"],
}),
/supports duration/
assert.equal(
(veo.modelSpecificPayload as Record<string, Record<string, unknown>>).parameters
.durationSeconds,
6
);
assert.equal(veo.generateAudio, true);
});
test("extractAdobeResultLink prefers x-override-status-link then links.result", () => {
const headers = new Headers({ "x-override-status-link": "https://poll.example/job/1" });
const headers = new Headers({
"x-override-status-link": "https://poll.example/job/1",
});
assert.equal(extractAdobeResultLink(headers, {}), "https://poll.example/job/1");
const headers2 = new Headers();
assert.equal(
extractAdobeResultLink(headers2, { links: { result: { href: "https://poll.example/job/2" } } }),
extractAdobeResultLink(headers2, {
links: { result: { href: "https://poll.example/job/2" } },
}),
"https://poll.example/job/2"
);
});
@@ -475,17 +411,46 @@ test("buildAdobeSubmitNonce is sha256(user_id + prompt[:256])", async () => {
assert.notEqual(buildAdobeSubmitNonce(token, prompt + "!"), nonce);
assert.equal(extractAdobeAccountIdFromToken(token), "0EB681AF6A5FF6C10A495FF2@AdobeID");
const {
isValidAdobeArpSessionId,
resolveAdobeArpSessionId,
extractAdobeArpSessionId,
ADOBE_FIREFLY_FTR_MAGIC,
} = await import("../../open-sse/services/adobeFireflyClient.ts");
const arp = buildAdobeArpSessionId();
assert.ok(arp.length > 20);
assert.equal(isValidAdobeArpSessionId(arp), true);
const decoded = JSON.parse(Buffer.from(arp, "base64").toString("utf8"));
assert.ok(decoded.sid);
assert.match(String(decoded.ftr), /dUAL43-mnts-ants-d4_31ck__tt$/);
// 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 });
assert.equal(h["x-nonce"], nonce);
assert.ok(h["x-arp-session-id"]);
assert.equal(h.cookie, undefined);
// Prefer real sherlockToken / x-arp-session-id from paste over synthetic
const realArp = Buffer.from(
JSON.stringify({
sid: "11111111-2222-3333-4444-555555555555",
ark: "sess.123|r=eu-west-1|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C",
ftr: "aa_1" + ADOBE_FIREFLY_FTR_MAGIC + "_x=-1-v2_tt",
}),
"utf8"
).toString("base64");
assert.equal(extractAdobeArpSessionId(`a=1; sherlockToken=${realArp}; b=2`), realArp);
assert.equal(
extractAdobeArpSessionId(`x-arp-session-id: ${realArp}\nAuthorization: Bearer x`),
realArp
);
assert.equal(resolveAdobeArpSessionId(`sherlockToken=${realArp}`), realArp);
});
test("normalizeAdobePollUrl rewrites firefly-epo jobs/result to BKS", () => {
@@ -503,7 +468,9 @@ test("parseAdobeCreditsBalance maps total + free/plan buckets", () => {
},
credits: {
firefly_free_credit: { quota: { total: 10, used: 0, available: 10 } },
firefly_plan_credit: { quota: { total: 10000, used: 10, available: 9990 } },
firefly_plan_credit: {
quota: { total: 10000, used: 10, available: 9990 },
},
},
});
assert.equal(bal.total, 10010);
@@ -529,7 +496,7 @@ test("adobe-firefly is in USAGE_SUPPORTED_PROVIDERS for Limits", () => {
assert.ok(USAGE_SUPPORTED_PROVIDERS.includes("firefly"));
});
test("parseAdobeModelsDiscovery preserves schemas and maps exact ids", () => {
test("parseAdobeModelsDiscovery extracts image/video versions", () => {
const rows = parseAdobeModelsDiscovery({
models: [
{
@@ -540,44 +507,16 @@ test("parseAdobeModelsDiscovery preserves schemas and maps exact ids", () => {
outputModality: ["image"],
modelDisplayName: "Gemini 3.0 (Nano Banana Pro)",
healthStatus: "HEALTHY",
inputMediaUseCase: ["editing"],
bksGenerationModel: "firefly_3p:external:gemini_flash_2",
requestSchema: {
type: "object",
properties: {
prompt: { type: "string" },
referenceBlobs: {
maxItems: 14,
"x-capabilities": [
{
mediaType: "image",
usageConstraints: [{ usageType: "general", minItems: 0, maxItems: 14 }],
maxFileSizeBytes: 104857600,
},
],
},
},
},
},
},
},
{
modelId: "veo",
modelId: "sora",
modelVersions: {
"3.1-generate": {
"sora-2": {
enabled: true,
outputModality: ["video"],
modelDisplayName: "Veo 3.1",
requestSchema: {
allOf: [
{
properties: {
prompt: { type: "string" },
duration: { anyOf: [{ type: "integer", enum: [4, 6, 8] }] },
},
},
],
},
modelDisplayName: "Sora 2",
},
},
},
@@ -587,35 +526,14 @@ test("parseAdobeModelsDiscovery preserves schemas and maps exact ids", () => {
assert.equal(rows[0].modality, "image");
assert.equal(rows[1].modality, "video");
const catalog = mapDiscoveredToCatalog(rows);
assert.ok(catalog.some((m) => m.id === "gemini-flash-nano-banana-2"));
assert.ok(catalog.some((m) => m.id === "veo-3.1-generate"));
assert.equal(catalog[0].capabilities.referenceInputs[0].maxItems, 14);
assert.deepEqual(catalog[1].capabilities.supportedDurations, [4, 6, 8]);
assert.ok(catalog.some((m) => m.id === "nano-banana-pro"));
assert.ok(catalog.some((m) => m.id === "sora-2"));
});
test("fallback catalog is the verified discovery snapshot without invented Sora", () => {
assert.equal(ADOBE_FIREFLY_FALLBACK_MODELS.length, 52);
assert.equal(getAdobeFireflyFallbackCatalog("image").length, 17);
assert.equal(getAdobeFireflyFallbackCatalog("video").length, 35);
assert.equal(
ADOBE_FIREFLY_FALLBACK_MODELS.some((model) => model.id.includes("sora")),
false
);
assert.equal(
ADOBE_FIREFLY_FALLBACK_MODELS.some(
(model) => model.id.includes("kling") && model.id.includes("omni")
),
false
);
assert.ok(ADOBE_FIREFLY_FALLBACK_MODELS.some((model) => model.id === "kling-kling-o3"));
assert.equal(
ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"].capabilities.referenceInputs[0].maxItems,
14
);
assert.equal(
ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"].capabilities.referenceInputs[0].maxItems,
16
);
test("fallback catalog has image and video entries from get_models capture", () => {
assert.ok(ADOBE_FIREFLY_FALLBACK_MODELS.length >= 10);
assert.ok(getAdobeFireflyFallbackCatalog("image").length >= 4);
assert.ok(getAdobeFireflyFallbackCatalog("video").length >= 4);
});
test("extractAdobeAccountIdFromToken reads user_id claim", () => {
@@ -630,7 +548,18 @@ test("extractAdobeAccountIdFromToken reads user_id claim", () => {
// --- Handlers (mocked fetch) ----------------------------------------------
function jsonResponse(status: number, body: unknown, headerMap: Record<string, string> = {}) {
return new Response(JSON.stringify(body) ?? null, { status, headers: headerMap });
return {
ok: status >= 200 && status < 300,
status,
headers: {
get: (name: string) => {
const key = Object.keys(headerMap).find((k) => k.toLowerCase() === name.toLowerCase());
return key ? headerMap[key] : null;
},
},
json: async () => body,
text: async () => JSON.stringify(body),
} as unknown as Response;
}
test("handleAdobeFireflyImageGeneration returns 400 when prompt is missing", async () => {
@@ -648,7 +577,11 @@ function userImsJwt(userId = "0EB@AdobeID"): string {
return (
`eyJhbGciOiJSUzI1NiJ9.` +
Buffer.from(
JSON.stringify({ user_id: userId, type: "access_token", client_id: "clio-playground-web" })
JSON.stringify({
user_id: userId,
type: "access_token",
client_id: "clio-playground-web",
})
).toString("base64url") +
`.` +
"sig".padEnd(40, "x")
@@ -729,7 +662,10 @@ test("handleAdobeFireflyImageGeneration uploads refs and submits referenceBlobs"
assert.equal(result.success, true);
assert.equal(uploadCalls, 1);
assert.ok(sawGenerateBody);
const refs = sawGenerateBody!.referenceBlobs as Array<{ id: string; usage: string }>;
const refs = sawGenerateBody!.referenceBlobs as Array<{
id: string;
usage: string;
}>;
assert.deepEqual(refs, [{ id: "ref-blob-1", usage: "general" }]);
});
@@ -755,7 +691,7 @@ test("adobeFireflyGenerateVideo submit+poll happy path (mocked)", async () => {
const result = await adobeFireflyGenerateVideo({
accessToken: "tok",
prompt: "drone over forest",
model: "veo-3.1",
model: "sora-2",
duration: 4,
aspectRatio: "16:9",
fetchImpl: fetchImpl as typeof fetch,
@@ -766,7 +702,7 @@ test("adobeFireflyGenerateVideo submit+poll happy path (mocked)", async () => {
test("handleAdobeFireflyVideoGeneration returns 400 without prompt", async () => {
const result = await handleAdobeFireflyVideoGeneration({
model: "veo-3.1",
model: "sora-2",
provider: "adobe-firefly",
body: {},
credentials: { apiKey: "aaa.bbb.ccc" },
@@ -854,6 +790,112 @@ test("cookie exchange rejects guest IMS tokens", async () => {
);
});
test("extractAdobeArpSessionId recovers JWT+ARP joined by space (PasswordBox mangling)", async () => {
const { extractAdobeArpSessionId, hasBrowserAdobeArpSession, formatAdobeSystemUnderLoadError } =
await import("../../open-sse/services/adobeFireflyClient.ts");
const realArp = Buffer.from(
JSON.stringify({
sid: "bdf37b8a-117f-467d-a737-7792932d98b4",
ark: "60818c561473ddb23.0684402805|r=eu-west-1|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C",
ftr: "aab9dc9eb48f4ee1916428649f908f7d_1__UDF43-m4_31ck_x=-1-v2_tt",
}),
"utf8"
).toString("base64");
// Fake 3-segment JWT shape long enough for looksLikeAdobeJwt
const fakeJwt =
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." +
"eyJ1c2VyX2lkIjoiMEVCNjgxQUY2QTVGRjZDMTBBNDk1RkYyQEFkb2JlSUQifQ." +
"sigABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop";
const joined = `${fakeJwt} ${realArp}`;
assert.equal(extractAdobeArpSessionId(joined), realArp);
assert.equal(hasBrowserAdobeArpSession(joined), true);
assert.equal(hasBrowserAdobeArpSession(fakeJwt), false);
assert.match(
formatAdobeSystemUnderLoadError("image", 2, { hadBrowserArp: false }),
/missing a browser x-arp-session-id/
);
assert.match(
formatAdobeSystemUnderLoadError("image", 2, { hadBrowserArp: true }),
/auto-rebuilds x-arp-session-id|Cookie once|Forter\/Arkose/i
);
});
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(
@@ -900,6 +942,10 @@ test("resolveAdobeImageModel maps gpt-image-2 alias", async () => {
});
test("image submit retries on 408 then succeeds", async () => {
const { __resetAdobeFireflySessionCacheForTests } =
await import("../../open-sse/services/adobeFireflySession.ts");
__resetAdobeFireflySessionCacheForTests();
let submits = 0;
const userTok = userImsJwt();
const fetchImpl = async (url: string) => {
@@ -907,7 +953,10 @@ test("image submit retries on 408 then succeeds", async () => {
if (u.includes("generate-async")) {
submits += 1;
if (submits < 3) {
return jsonResponse(408, { error_code: "timeout_error", message: "system under load" });
return jsonResponse(408, {
error_code: "timeout_error",
message: "system under load",
});
}
return jsonResponse(200, { links: { result: { href: "https://poll.example/job/r1" } } }, {});
}
@@ -930,7 +979,106 @@ test("image submit retries on 408 then succeeds", async () => {
assert.match(result.url, /retry\.png/);
});
test("sticky ARP: successful submit is reused by ensure on next call", async () => {
const {
__resetAdobeFireflySessionCacheForTests,
markAdobeFireflyArpSuccess,
ensureAdobeFireflySession,
fingerprintAdobeCredential,
} = await import("../../open-sse/services/adobeFireflySession.ts");
const { ADOBE_FIREFLY_FTR_MAGIC } = await import("../../open-sse/services/adobeFireflyClient.ts");
__resetAdobeFireflySessionCacheForTests();
const userTok = userImsJwt();
const ftr = `aab9dc9eb48f4ee1916428649f908f7d_${Date.now()}${ADOBE_FIREFLY_FTR_MAGIC}_x=-1-v2_tt`;
const ark =
"87818c58b11662a57.5347274705|r=eu-west-1|meta=3|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C|at=40";
const cookie =
`ff_session_guid=bdf37b8a-117f-467d-a737-7792932d98b4; arkose=${ark}; ` +
`forterToken=${encodeURIComponent(ftr)}`;
const cred = `${userTok}\n${cookie}`;
const fp = fingerprintAdobeCredential(cred);
const stickyArp = Buffer.from(
JSON.stringify({ sid: "sticky-sid", ark: "sticky-ark", ftr: "sticky-ftr" }),
"utf8"
).toString("base64");
markAdobeFireflyArpSuccess(fp, stickyArp);
const session = await ensureAdobeFireflySession({
credentials: { apiKey: cred },
allowBrowserRefresh: false,
fetchImpl: (async () => {
throw new Error("no network expected");
}) as typeof fetch,
});
assert.equal(session.arpSessionId, stickyArp, "ensure must stick to last successful ARP");
assert.equal(session.fingerprint, fp);
});
test("rotateAdobeFireflySessionOnError: attempt1-2 reuse sticky; attempt3 keeps ARP without browser", async () => {
const {
__resetAdobeFireflySessionCacheForTests,
rotateAdobeFireflySessionOnError,
markAdobeFireflyArpSuccess,
buildAdobeArpSessionIdFromCookies,
} = await import("../../open-sse/services/adobeFireflySession.ts");
const { ADOBE_FIREFLY_FTR_MAGIC } = await import("../../open-sse/services/adobeFireflyClient.ts");
__resetAdobeFireflySessionCacheForTests();
const ftr = `aa_${Date.now()}${ADOBE_FIREFLY_FTR_MAGIC}_x=-1-v2_tt`;
const cookie = `ff_session_guid=sid-1; arkose=ark-1; forterToken=${encodeURIComponent(ftr)}`;
const rebuilt = buildAdobeArpSessionIdFromCookies(cookie);
assert.ok(rebuilt);
const base = {
accessToken: userImsJwt(),
cookie,
arpSessionId: rebuilt!,
tokenExpiresAt: Date.now() + 3600_000,
updatedAt: Date.now(),
fingerprint: "fp-rotate-test",
source: "paste" as const,
};
markAdobeFireflyArpSuccess(base.fingerprint, rebuilt!);
const a1 = await rotateAdobeFireflySessionOnError(base, {
attempt: 1,
tryBrowser: false,
});
assert.equal(a1.arpSessionId, rebuilt, "attempt 1 reuses ARP (rate-limit quiet)");
const a2 = await rotateAdobeFireflySessionOnError(
{ ...base, arpSessionId: rebuilt! },
{ attempt: 2, tryBrowser: false }
);
assert.equal(a2.arpSessionId, rebuilt, "attempt 2 still reuses ARP (mid-batch quiet)");
const a3 = await rotateAdobeFireflySessionOnError(
{ ...base, arpSessionId: rebuilt! },
{ attempt: 3, tryBrowser: false }
);
// attempt 3 without browser: cookie rebuild (identical forter → same ARP)
assert.ok(a3.arpSessionId, "attempt 3 still yields an ARP");
assert.equal(
a3.arpSessionId,
rebuilt,
"identical cookie rebuild keeps ARP (no synthetic thrash)"
);
const auth = await rotateAdobeFireflySessionOnError(
{ ...base, arpSessionId: rebuilt!, tokenExpiresAt: 0 },
{ attempt: 1, authFailure: true, tryBrowser: false }
);
assert.equal(auth.source, "rebuild", "auth failures must bypass quiet sticky reuse");
assert.ok(auth.tokenExpiresAt > Date.now(), "retry must repair a missing token expiry");
});
test("adobeFireflyGenerateImage cookie path exchanges IMS token first", async () => {
const { __resetAdobeFireflySessionCacheForTests } =
await import("../../open-sse/services/adobeFireflySession.ts");
__resetAdobeFireflySessionCacheForTests();
const userTok =
`eyJhbGciOiJSUzI1NiJ9.` +
Buffer.from(
@@ -948,7 +1096,10 @@ test("adobeFireflyGenerateImage cookie path exchanges IMS token first", async ()
if (String(url).includes("ims/check")) {
assert.equal(init?.method, "POST");
// Authenticated exchange (guest_allowed=false)
return jsonResponse(200, { access_token: userTok, account_type: "type1" });
return jsonResponse(200, {
access_token: userTok,
account_type: "type1",
});
}
if (String(url).includes("generate-async")) {
const auth =