diff --git a/src/platforms/core/auth/chromium-worker.ts b/src/platforms/core/auth/chromium-worker.ts index 20cc5f9d..036b2e7a 100644 --- a/src/platforms/core/auth/chromium-worker.ts +++ b/src/platforms/core/auth/chromium-worker.ts @@ -1,7 +1,12 @@ import { execFileSync } from "node:child_process"; import { mkdirSync } from "node:fs"; -import { type BrowserContext, type Cookie, chromium, type Page, type Request } from "playwright"; +import { type BrowserContext, type Cookie, chromium, type Page } from "playwright"; import { cuedAuthKeychainService } from "../../../core/identity.js"; +import { + captureLinkedInSessionData, + LinkedInRealtimeCaptureError, +} from "../../linkedin/auth/realtime-capture.js"; +import { buildLinkedInRealtimeAuthResult } from "../../linkedin/auth/session-store.js"; import { type ChromiumAuthFailureCode, classifyChromiumAuthWaitFailure, @@ -240,73 +245,6 @@ async function fetchDiscordCurrentUser(token: string): Promise<{ }; } -type LinkedInCapture = { - pageInstance: string | null; - xLiTrack: string | null; - serviceVersion: string | null; - realtimeQueryMap: string | null; - realtimeRecipeMap: string | null; -}; - -function captureLinkedInHeaders(capture: LinkedInCapture, request: Request): void { - if (!request.url().includes("linkedin.com")) { - return; - } - - const headers = request.headers(); - if (!capture.pageInstance && typeof headers["x-li-page-instance"] === "string") { - capture.pageInstance = headers["x-li-page-instance"]; - } - if (!capture.xLiTrack && typeof headers["x-li-track"] === "string") { - capture.xLiTrack = headers["x-li-track"]; - try { - const parsed = JSON.parse(headers["x-li-track"]) as Record; - if (typeof parsed.mpVersion === "string") { - capture.serviceVersion = parsed.mpVersion; - } else if (typeof parsed.clientVersion === "string") { - capture.serviceVersion = parsed.clientVersion; - } - } catch { - capture.serviceVersion = null; - } - } - if (request.url().includes("/realtime/connect")) { - if (!capture.realtimeQueryMap && typeof headers["x-li-query-map"] === "string") { - capture.realtimeQueryMap = headers["x-li-query-map"]; - } - if (!capture.realtimeRecipeMap && typeof headers["x-li-recipe-map"] === "string") { - capture.realtimeRecipeMap = headers["x-li-recipe-map"]; - } - } -} - -async function captureLinkedInSessionData( - context: BrowserContext, - page: Page, -): Promise { - const capture: LinkedInCapture = { - pageInstance: null, - xLiTrack: null, - serviceVersion: null, - realtimeQueryMap: null, - realtimeRecipeMap: null, - }; - const onRequest = (request: Request) => { - captureLinkedInHeaders(capture, request); - }; - context.on("request", onRequest); - try { - if (!isLinkedInMessagingUrl(page.url())) { - await page.goto("https://www.linkedin.com/messaging/", { waitUntil: "domcontentloaded" }); - } - await page.waitForLoadState("domcontentloaded", { timeout: 5_000 }).catch(() => {}); - await page.waitForTimeout(500); - return capture; - } finally { - context.off("request", onRequest); - } -} - async function extractSlackAuth( context: BrowserContext, page: Page, @@ -394,13 +332,7 @@ async function extractLinkedInAuth( } const userAgent = await page.evaluate(() => navigator.userAgent); - const captured = await captureLinkedInSessionData(context, page); - const realtimeReady = Boolean( - captured.pageInstance && - captured.xLiTrack && - captured.realtimeQueryMap && - captured.realtimeRecipeMap, - ); + const captured = await captureLinkedInSessionData(page); return { keychainService: cuedAuthKeychainService("linkedin"), @@ -431,12 +363,7 @@ async function extractLinkedInAuth( runtime: runtimeKind(), cookieCount: cookies.length, userAgentCaptured: userAgent.length > 0, - realtimeReady, - pageInstanceCaptured: Boolean(captured.pageInstance), - xLiTrackCaptured: Boolean(captured.xLiTrack), - realtimeQueryMapCaptured: Boolean(captured.realtimeQueryMap), - realtimeRecipeMapCaptured: Boolean(captured.realtimeRecipeMap), - serviceVersion: captured.serviceVersion, + ...buildLinkedInRealtimeAuthResult(captured), }, }; } @@ -608,6 +535,19 @@ async function run(): Promise { return; } catch (error) { lastExtractionError = error instanceof Error ? error.message : String(error); + if (error instanceof LinkedInRealtimeCaptureError) { + process.stdout.write( + JSON.stringify( + buildChromiumFailureResult({ + args, + errorCode: "auth_extraction_failed", + stage: "linkedin_realtime_capture", + pageSummary: summarizeOpenPages(args.platform, openPages), + }), + ), + ); + return; + } } } diff --git a/src/platforms/core/state/integration-state.test.ts b/src/platforms/core/state/integration-state.test.ts index 82c9b1f4..2a71d559 100644 --- a/src/platforms/core/state/integration-state.test.ts +++ b/src/platforms/core/state/integration-state.test.ts @@ -838,6 +838,10 @@ process.exit(44); { name: "li_at", value: "li_at-token" }, { name: "JSESSIONID", value: '"ajax:123"' }, ], + pageInstance: "urn:li:page:messaging;test", + xLiTrack: "{}", + realtimeQueryMap: "{}", + realtimeRecipeMap: "{}", savedAt: 1234, }, }); @@ -857,6 +861,7 @@ process.exit(44); keychainService: "so.cued.desktop.auth.linkedin", keychainAccount: "default", importedSavedAt: 1234, + authResult: expect.objectContaining({ realtimeReady: true }), }), }), ]), @@ -870,6 +875,102 @@ process.exit(44); db.close(); }); + it("marks incomplete stored LinkedIn auth as needing authentication", async () => { + installSecurityTool({ + "so.cued.desktop.auth.linkedin:default": { + cookies: [ + { name: "li_at", value: "li_at-token" }, + { name: "JSESSIONID", value: '"ajax:123"' }, + ], + }, + }); + process.env.CUED_SLACK_APP_BINARY = join(createTempDir("cued-no-slack-app-"), "Slack"); + + const db = createDb(); + db.upsertIntegrationState({ + platform: "linkedin", + accountKey: "default", + displayName: "LinkedIn", + authState: "authenticated", + enabled: true, + connectionKind: "browser-session", + syncCapable: true, + launchStrategy: "chromium-auth", + launchTarget: "https://www.linkedin.com/login", + importedFrom: "local-cli", + metadata: { + keychainService: "so.cued.desktop.auth.linkedin", + keychainAccount: "default", + }, + }); + + await refreshManagedIntegrationStates(db); + + expect(listIntegrationStates(db)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + platform: "linkedin", + authState: "needs_auth", + syncCapable: false, + metadata: expect.objectContaining({ + authInvalidationReason: "linkedin_realtime_headers_missing", + }), + }), + ]), + ); + db.close(); + }); + + it("repairs realtime metadata from a complete stored LinkedIn session", async () => { + installSecurityTool({ + "so.cued.desktop.auth.linkedin:default": { + cookies: [ + { name: "li_at", value: "li_at-token" }, + { name: "JSESSIONID", value: '"ajax:123"' }, + ], + pageInstance: "urn:li:page:messaging;test", + xLiTrack: "{}", + realtimeQueryMap: "{}", + realtimeRecipeMap: "{}", + }, + }); + process.env.CUED_SLACK_APP_BINARY = join(createTempDir("cued-no-slack-app-"), "Slack"); + + const db = createDb(); + db.upsertIntegrationState({ + platform: "linkedin", + accountKey: "default", + displayName: "LinkedIn", + authState: "authenticated", + enabled: true, + connectionKind: "browser-session", + syncCapable: true, + launchStrategy: "chromium-auth", + launchTarget: "https://www.linkedin.com/login", + importedFrom: "local-cli", + metadata: { + keychainService: "so.cued.desktop.auth.linkedin", + keychainAccount: "default", + authResult: { realtimeReady: false }, + }, + }); + + await refreshManagedIntegrationStates(db); + + expect(listIntegrationStates(db)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + platform: "linkedin", + authState: "authenticated", + metadata: expect.objectContaining({ + authResult: expect.objectContaining({ realtimeReady: true }), + }), + }), + ]), + ); + db.close(); + }); + it("revives user-removed LinkedIn auth only during explicit connect import", () => { installSecurityTool({ "so.cued.desktop.auth.linkedin:default": { @@ -877,6 +978,10 @@ process.exit(44); { name: "li_at", value: "li_at-token" }, { name: "JSESSIONID", value: '"ajax:123"' }, ], + pageInstance: "urn:li:page:messaging;test", + xLiTrack: "{}", + realtimeQueryMap: "{}", + realtimeRecipeMap: "{}", savedAt: 1234, }, }); diff --git a/src/platforms/linkedin/auth/keychain-import.test.ts b/src/platforms/linkedin/auth/keychain-import.test.ts index 5d9a7f96..5a947f15 100644 --- a/src/platforms/linkedin/auth/keychain-import.test.ts +++ b/src/platforms/linkedin/auth/keychain-import.test.ts @@ -19,6 +19,8 @@ const storedSession = { userAgent: "Cued LinkedIn Test/1.0", pageInstance: "urn:li:page:messaging_thread;test", xLiTrack: '{"clientVersion":"1.0.0"}', + realtimeQueryMap: "{}", + realtimeRecipeMap: "{}", }; describe("LinkedIn stored auth validation", () => { @@ -56,6 +58,20 @@ describe("LinkedIn stored auth validation", () => { }); }); + it("rejects sessions that cannot start realtime before probing LinkedIn", async () => { + loadKeychainSecretMock.mockReturnValue({ + ...storedSession, + realtimeQueryMap: null, + }); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect(validateLinkedInStoredAuth("default")).resolves.toEqual({ + status: "invalid", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("returns indeterminate for transient network failures", async () => { loadKeychainSecretMock.mockReturnValue(storedSession); vi.stubGlobal( diff --git a/src/platforms/linkedin/auth/keychain-import.ts b/src/platforms/linkedin/auth/keychain-import.ts index 5a67c2b8..44a18bb9 100644 --- a/src/platforms/linkedin/auth/keychain-import.ts +++ b/src/platforms/linkedin/auth/keychain-import.ts @@ -7,11 +7,16 @@ import { loadKeychainSecret, storeKeychainSecret, } from "../../core/secrets/keychain.js"; -import { completeAuthSession } from "../../core/state/mutations.js"; +import { completeAuthSession, invalidateIntegrationAuth } from "../../core/state/mutations.js"; import { isUserRemovedIntegrationMetadata } from "../../core/state/status.js"; import { LinkedInClient } from "../api/client.js"; import { LinkedInAuthError } from "../api/request.js"; -import { type LinkedInSessionSecret, parseLinkedInSessionSecret } from "./session-store.js"; +import { + buildLinkedInRealtimeAuthResult, + hasLinkedInRealtimeSessionHeaders, + type LinkedInSessionSecret, + parseLinkedInSessionSecret, +} from "./session-store.js"; const LINKEDIN_KEYCHAIN_SERVICE = authKeychainService("linkedin"); const LEGACY_LINKEDIN_KEYCHAIN_SERVICE = legacyAuthKeychainService("linkedin"); @@ -69,7 +74,7 @@ export async function validateLinkedInStoredAuth( } const session = parseLinkedInSessionSecret(secret); - if (!hasRequiredCookies(getCookieNames(session))) { + if (!hasRequiredCookies(getCookieNames(session)) || !hasLinkedInRealtimeSessionHeaders(session)) { return { status: "invalid" }; } @@ -116,6 +121,16 @@ export function importLinkedInStoredAuth( if (userRemoved && !options.reviveUserRemoved) { return []; } + if (!hasLinkedInRealtimeSessionHeaders(parsed)) { + if (existing?.auth_state === "authenticated" && !userRemoved) { + invalidateIntegrationAuth(db, "linkedin", accountKey, { + errorSummary: "LinkedIn realtime headers were not captured during auth", + reason: "linkedin_realtime_headers_missing", + }); + } + return []; + } + const realtimeAuthResult = buildLinkedInRealtimeAuthResult(parsed); const alreadyImported = existing?.auth_state === "authenticated" && typeof existingMetadata.keychainService === "string" && @@ -123,6 +138,32 @@ export function importLinkedInStoredAuth( typeof existingMetadata.keychainAccount === "string" && existingMetadata.keychainAccount === accountKey; if (alreadyImported) { + const existingAuthResult = + typeof existingMetadata.authResult === "object" && existingMetadata.authResult + ? (existingMetadata.authResult as Record) + : {}; + if (existingAuthResult.realtimeReady !== true) { + db.upsertIntegrationState({ + platform: "linkedin", + accountKey, + displayName: existing.display_name, + authState: existing.auth_state, + enabled: existing.enabled === 1, + connectionKind: existing.connection_kind, + syncCapable: existing.sync_capable === 1, + launchStrategy: existing.launch_strategy, + launchTarget: existing.launch_target, + importedFrom: existing.imported_from, + artifactPaths: safeParseJsonStringArray( + existing.artifact_paths_json, + "integration_states.artifact_paths_json", + ), + metadata: { + ...existingMetadata, + authResult: { ...existingAuthResult, ...realtimeAuthResult }, + }, + }); + } return [{ platform: "linkedin", accountKey, imported: false }]; } @@ -168,6 +209,7 @@ export function importLinkedInStoredAuth( provider: "linkedin", savedAt: parsed.savedAt, cookieNames, + ...realtimeAuthResult, }, }); @@ -179,6 +221,7 @@ export function importLinkedInStoredAuth( provider: "linkedin", savedAt: parsed.savedAt, cookieNames, + ...realtimeAuthResult, }, }); diff --git a/src/platforms/linkedin/auth/realtime-capture.test.ts b/src/platforms/linkedin/auth/realtime-capture.test.ts new file mode 100644 index 00000000..a27c72a4 --- /dev/null +++ b/src/platforms/linkedin/auth/realtime-capture.test.ts @@ -0,0 +1,78 @@ +import type { Page, Request } from "playwright"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { captureLinkedInSessionData, LinkedInRealtimeCaptureError } from "./realtime-capture.js"; + +function request(url: string, headers: Record): Request { + return { url: () => url, headers: () => headers } as Request; +} + +function captureHarness(requests: Request[]) { + let listener: ((request: Request) => void) | null = null; + const page = { + url: () => "https://www.linkedin.com/messaging/", + on: vi.fn((_event: string, nextListener: (request: Request) => void) => { + listener = nextListener; + return page; + }), + off: vi.fn(() => page), + goto: vi.fn(async () => { + for (const nextRequest of requests) { + listener?.(nextRequest); + } + return null; + }), + } as unknown as Page; + return page; +} + +describe("LinkedIn realtime auth capture", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("navigates after subscribing when auth already landed on Messaging", async () => { + const page = captureHarness([ + request("https://www.linkedin.com/voyager/api/me", { + "x-li-page-instance": "urn:li:page:unrelated;wrong", + "x-li-track": '{"clientVersion":"0.0.0"}', + }), + request("https://www.linkedin.com/realtime/connect?rc=1", { + "x-li-page-instance": "urn:li:page:messaging;test", + "x-li-track": '{"clientVersion":"1.2.3"}', + "x-li-query-map": '{"query":"map"}', + "x-li-recipe-map": '{"recipe":"map"}', + }), + ]); + + await expect(captureLinkedInSessionData(page)).resolves.toEqual({ + pageInstance: "urn:li:page:messaging;test", + xLiTrack: '{"clientVersion":"1.2.3"}', + serviceVersion: "1.2.3", + realtimeQueryMap: '{"query":"map"}', + realtimeRecipeMap: '{"recipe":"map"}', + }); + expect(page.goto).toHaveBeenCalledWith("https://www.linkedin.com/messaging/", { + waitUntil: "domcontentloaded", + timeout: 10_000, + }); + expect(page.on).toHaveBeenCalledOnce(); + expect(page.off).toHaveBeenCalledOnce(); + }); + + it("rejects incomplete captures instead of authenticating a degraded session", async () => { + vi.useFakeTimers(); + const page = captureHarness([ + request("https://www.linkedin.com/realtime/connect?rc=1", { + "x-li-page-instance": "urn:li:page:messaging;test", + "x-li-track": "{}", + }), + ]); + + const capture = captureLinkedInSessionData(page); + const rejected = expect(capture).rejects.toBeInstanceOf(LinkedInRealtimeCaptureError); + await vi.advanceTimersByTimeAsync(10_000); + + await rejected; + expect(page.off).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/platforms/linkedin/auth/realtime-capture.ts b/src/platforms/linkedin/auth/realtime-capture.ts new file mode 100644 index 00000000..a51da9e0 --- /dev/null +++ b/src/platforms/linkedin/auth/realtime-capture.ts @@ -0,0 +1,101 @@ +import type { Page, Request } from "playwright"; +import { + type CompleteLinkedInRealtimeSessionHeaders, + hasLinkedInRealtimeSessionHeaders, +} from "./session-store.js"; + +const LINKEDIN_MESSAGING_URL = "https://www.linkedin.com/messaging/"; +const LINKEDIN_REALTIME_CAPTURE_TIMEOUT_MS = 10_000; + +export type LinkedInRealtimeCapture = CompleteLinkedInRealtimeSessionHeaders & { + serviceVersion: string | null; +}; + +export class LinkedInRealtimeCaptureError extends Error { + constructor() { + super("LinkedIn realtime headers were not captured during auth"); + this.name = "LinkedInRealtimeCaptureError"; + } +} + +function header(headers: Record, name: string): string | null { + const value = headers[name]?.trim(); + return value ? value : null; +} + +function parseServiceVersion(xLiTrack: string): string | null { + try { + const parsed = JSON.parse(xLiTrack) as Record; + if (typeof parsed.mpVersion === "string") { + return parsed.mpVersion; + } + return typeof parsed.clientVersion === "string" ? parsed.clientVersion : null; + } catch { + return null; + } +} + +function captureLinkedInRealtimeRequest(request: Request): LinkedInRealtimeCapture | null { + let url: URL; + try { + url = new URL(request.url()); + } catch { + return null; + } + if (url.hostname !== "www.linkedin.com" || url.pathname !== "/realtime/connect") { + return null; + } + const headers = request.headers(); + const candidate = { + pageInstance: header(headers, "x-li-page-instance"), + xLiTrack: header(headers, "x-li-track"), + realtimeQueryMap: header(headers, "x-li-query-map"), + realtimeRecipeMap: header(headers, "x-li-recipe-map"), + }; + if (!hasLinkedInRealtimeSessionHeaders(candidate)) { + return null; + } + return { + ...candidate, + serviceVersion: parseServiceVersion(candidate.xLiTrack), + }; +} + +export async function captureLinkedInSessionData(page: Page): Promise { + let resolveReady: ((capture: LinkedInRealtimeCapture) => void) | null = null; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + const onRequest = (request: Request) => { + const capture = captureLinkedInRealtimeRequest(request); + if (capture) { + resolveReady?.(capture); + } + }; + page.on("request", onRequest); + + let timeout: ReturnType | null = null; + try { + // Always navigate after subscribing. If auth lands directly on Messaging, its + // initial realtime request has already fired before extraction begins. + void page + .goto(LINKEDIN_MESSAGING_URL, { + waitUntil: "domcontentloaded", + timeout: LINKEDIN_REALTIME_CAPTURE_TIMEOUT_MS, + }) + .catch(() => null); + const expired = new Promise((resolve) => { + timeout = setTimeout(() => resolve(null), LINKEDIN_REALTIME_CAPTURE_TIMEOUT_MS); + }); + const capture = await Promise.race([ready, expired]); + if (!capture) { + throw new LinkedInRealtimeCaptureError(); + } + return capture; + } finally { + if (timeout) { + clearTimeout(timeout); + } + page.off("request", onRequest); + } +} diff --git a/src/platforms/linkedin/auth/session-store.test.ts b/src/platforms/linkedin/auth/session-store.test.ts index e1cebcd7..60417829 100644 --- a/src/platforms/linkedin/auth/session-store.test.ts +++ b/src/platforms/linkedin/auth/session-store.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { parseLinkedInSessionSecret } from "./session-store.js"; +import { + buildLinkedInRealtimeAuthResult, + hasLinkedInRealtimeSessionHeaders, + parseLinkedInSessionSecret, +} from "./session-store.js"; describe("LinkedIn session storage", () => { it("preserves the browser user agent with the captured session", () => { @@ -10,4 +14,42 @@ describe("LinkedIn session storage", () => { }).userAgent, ).toBe("Mozilla/5.0 Chrome/150.0.0.0"); }); + + it("requires the complete realtime header tuple", () => { + expect( + hasLinkedInRealtimeSessionHeaders({ + pageInstance: "urn:li:page:messaging;test", + xLiTrack: "{}", + realtimeQueryMap: "{}", + realtimeRecipeMap: "{}", + }), + ).toBe(true); + expect( + hasLinkedInRealtimeSessionHeaders({ + pageInstance: "urn:li:page:messaging;test", + xLiTrack: "{}", + realtimeQueryMap: null, + realtimeRecipeMap: "{}", + }), + ).toBe(false); + }); + + it("builds readiness diagnostics from the same header invariant", () => { + expect( + buildLinkedInRealtimeAuthResult({ + pageInstance: "urn:li:page:messaging;test", + xLiTrack: "{}", + realtimeQueryMap: "{}", + realtimeRecipeMap: "{}", + serviceVersion: "1.2.3", + }), + ).toEqual({ + realtimeReady: true, + pageInstanceCaptured: true, + xLiTrackCaptured: true, + realtimeQueryMapCaptured: true, + realtimeRecipeMapCaptured: true, + serviceVersion: "1.2.3", + }); + }); }); diff --git a/src/platforms/linkedin/auth/session-store.ts b/src/platforms/linkedin/auth/session-store.ts index 51670192..26cb1deb 100644 --- a/src/platforms/linkedin/auth/session-store.ts +++ b/src/platforms/linkedin/auth/session-store.ts @@ -12,6 +12,22 @@ export interface LinkedInSessionSecret { savedAt: number | null; } +export type LinkedInRealtimeSessionHeaders = Pick< + LinkedInSessionSecret, + "pageInstance" | "xLiTrack" | "realtimeQueryMap" | "realtimeRecipeMap" +>; + +export type CompleteLinkedInRealtimeSessionHeaders = { + pageInstance: string; + xLiTrack: string; + realtimeQueryMap: string; + realtimeRecipeMap: string; +}; + +export type LinkedInRealtimeSessionHeaderSource = LinkedInRealtimeSessionHeaders & { + serviceVersion?: string | null; +}; + function asString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } @@ -30,6 +46,28 @@ export function parseLinkedInSessionSecret(secret: Record): Lin }; } +export function hasLinkedInRealtimeSessionHeaders( + session: LinkedInRealtimeSessionHeaders, +): session is CompleteLinkedInRealtimeSessionHeaders { + return Boolean( + session.pageInstance && + session.xLiTrack && + session.realtimeQueryMap && + session.realtimeRecipeMap, + ); +} + +export function buildLinkedInRealtimeAuthResult(source: LinkedInRealtimeSessionHeaderSource) { + return { + realtimeReady: hasLinkedInRealtimeSessionHeaders(source), + pageInstanceCaptured: Boolean(source.pageInstance), + xLiTrackCaptured: Boolean(source.xLiTrack), + realtimeQueryMapCaptured: Boolean(source.realtimeQueryMap), + realtimeRecipeMapCaptured: Boolean(source.realtimeRecipeMap), + serviceVersion: source.serviceVersion ?? null, + }; +} + export function loadLinkedInSessionSecret(accountKey: string): LinkedInSessionSecret { return parseLinkedInSessionSecret(loadIntegrationSecret("linkedin", accountKey).secret); } diff --git a/src/runtime/daemon/server.ts b/src/runtime/daemon/server.ts index a0a35fe0..7d213b70 100644 --- a/src/runtime/daemon/server.ts +++ b/src/runtime/daemon/server.ts @@ -54,7 +54,10 @@ import { isDiscordDmChannel } from "../../platforms/discord/types.js"; import { GmailClient } from "../../platforms/gmail/api/client.js"; import { DEFAULT_CALL_HISTORY_DB_PATH } from "../../platforms/imessage/call-history.js"; import { DEFAULT_CHAT_DB_PATH } from "../../platforms/imessage/reader.js"; -import { loadLinkedInSessionSecret } from "../../platforms/linkedin/auth/session-store.js"; +import { + hasLinkedInRealtimeSessionHeaders, + loadLinkedInSessionSecret, +} from "../../platforms/linkedin/auth/session-store.js"; import { buildLinkedInRawEventsFromRealtimeEnvelope } from "../../platforms/linkedin/realtime/events.js"; import { type LinkedInRealtimeStatus, @@ -1800,13 +1803,7 @@ async function collectDesiredLinkedInSessions(db: ReturnType