salesprompter-cli 0.1.19 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,751 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createClient } from "@supabase/supabase-js";
6
+ const COOKIE_VAULT_KEY_CONTEXT = "salesprompter:linkedin-session-cookie:v1";
7
+ const AES_256_KEY_BYTES = 32;
8
+ const REQUEST_TIMEOUT_MS = 20_000;
9
+ const DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36";
10
+ const MAX_COOKIE_VALIDATION_ATTEMPTS = 8;
11
+ const COOKIE_VALIDATION_TIMEOUT_MS = 75_000;
12
+ const COOKIE_PROBE_TIMEOUT_MS = 8_000;
13
+ const DEFAULT_EXCLUDED_LINKEDIN_SESSION_USER_EMAILS = ["hello@danielsinewe.com"];
14
+ const DEFAULT_EXCLUDED_LINKEDIN_SESSION_USER_HANDLES = ["danielsinewe"];
15
+ const ACTIVE_LINKEDIN_SESSION_LOOKBACK_HOURS = 72;
16
+ const MAX_RECOVERABLE_LINKEDIN_SESSION_CANDIDATES = 12;
17
+ const RECOVERABLE_LINKEDIN_SESSION_INACTIVE_REASON_PREFIX = "quarantined_browser_unverified";
18
+ const ENV_FILE_PATHS = [
19
+ resolve(dirname(fileURLToPath(import.meta.url)), "..", ".env.local"),
20
+ resolve(dirname(fileURLToPath(import.meta.url)), "..", ".env"),
21
+ resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "salesprompter-app", ".env.local"),
22
+ resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "salesprompter-app", ".env.vercel.local")
23
+ ];
24
+ const envFileCache = new Map();
25
+ function normalizeSessionCookie(sessionCookie) {
26
+ const trimmed = sessionCookie?.trim() || "";
27
+ return trimmed.length > 0 ? trimmed : null;
28
+ }
29
+ function normalizeIdentityValue(value) {
30
+ const trimmed = value?.trim().toLowerCase() || "";
31
+ return trimmed.length > 0 ? trimmed : null;
32
+ }
33
+ function dedupeIdentityValues(values) {
34
+ return Array.from(new Set(values
35
+ .map((value) => normalizeIdentityValue(value))
36
+ .filter((value) => Boolean(value))));
37
+ }
38
+ function parseIdentityEnvList(value) {
39
+ return dedupeIdentityValues((value || "").split(","));
40
+ }
41
+ function parseDotEnvFile(filePath) {
42
+ if (envFileCache.has(filePath)) {
43
+ return envFileCache.get(filePath);
44
+ }
45
+ const parsed = new Map();
46
+ if (existsSync(filePath)) {
47
+ const content = readFileSync(filePath, "utf8");
48
+ for (const line of content.split(/\r?\n/)) {
49
+ const trimmed = line.trim();
50
+ if (trimmed.length === 0 || trimmed.startsWith("#")) {
51
+ continue;
52
+ }
53
+ const separatorIndex = trimmed.indexOf("=");
54
+ if (separatorIndex <= 0) {
55
+ continue;
56
+ }
57
+ const key = trimmed.slice(0, separatorIndex).trim();
58
+ let value = trimmed.slice(separatorIndex + 1).trim();
59
+ if ((value.startsWith('"') && value.endsWith('"')) ||
60
+ (value.startsWith("'") && value.endsWith("'"))) {
61
+ value = value.slice(1, -1);
62
+ }
63
+ if (key.length > 0 && value.length > 0) {
64
+ parsed.set(key, value);
65
+ }
66
+ }
67
+ }
68
+ envFileCache.set(filePath, parsed);
69
+ return parsed;
70
+ }
71
+ function resolveConfiguredEnvValue(env, key) {
72
+ const directValue = env[key]?.trim() || "";
73
+ if (directValue.length > 0) {
74
+ return directValue;
75
+ }
76
+ if (env !== process.env) {
77
+ return null;
78
+ }
79
+ for (const filePath of ENV_FILE_PATHS) {
80
+ const parsed = parseDotEnvFile(filePath);
81
+ const value = parsed.get(key)?.trim() || "";
82
+ if (value.length > 0) {
83
+ return value;
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ function isTruthySetting(value) {
89
+ return ["1", "true", "on", "yes"].includes(value?.trim().toLowerCase() || "");
90
+ }
91
+ function isFalsySetting(value) {
92
+ return ["0", "false", "off", "no"].includes(value?.trim().toLowerCase() || "");
93
+ }
94
+ export function resolveCliLinkedInSessionExclusions(env = process.env) {
95
+ return {
96
+ userEmails: dedupeIdentityValues([
97
+ ...DEFAULT_EXCLUDED_LINKEDIN_SESSION_USER_EMAILS,
98
+ ...parseIdentityEnvList(resolveConfiguredEnvValue(env, "SALESPROMPTER_LINKEDIN_SESSION_EXCLUDED_EMAILS"))
99
+ ]),
100
+ userHandles: dedupeIdentityValues([
101
+ ...DEFAULT_EXCLUDED_LINKEDIN_SESSION_USER_HANDLES,
102
+ ...parseIdentityEnvList(resolveConfiguredEnvValue(env, "SALESPROMPTER_LINKEDIN_SESSION_EXCLUDED_HANDLES"))
103
+ ])
104
+ };
105
+ }
106
+ function isExcludedLinkedInSessionIdentity(identity, exclusions) {
107
+ const normalizedEmail = normalizeIdentityValue(identity.userEmail);
108
+ if (normalizedEmail && exclusions.userEmails.includes(normalizedEmail)) {
109
+ return true;
110
+ }
111
+ const normalizedHandle = normalizeIdentityValue(identity.userHandle);
112
+ return Boolean(normalizedHandle && exclusions.userHandles.includes(normalizedHandle));
113
+ }
114
+ function getCookieVaultKey(env = process.env) {
115
+ const secret = resolveConfiguredEnvValue(env, "LINKEDIN_SESSION_COOKIE_ENCRYPTION_KEY") ||
116
+ resolveConfiguredEnvValue(env, "EXTENSION_AUTH_SECRET") ||
117
+ resolveConfiguredEnvValue(env, "CLERK_SECRET_KEY") ||
118
+ null;
119
+ if (!secret) {
120
+ return null;
121
+ }
122
+ return createHash("sha256")
123
+ .update(`${COOKIE_VAULT_KEY_CONTEXT}:${secret}`)
124
+ .digest()
125
+ .subarray(0, AES_256_KEY_BYTES);
126
+ }
127
+ export function sessionCookieHash(sessionCookie) {
128
+ const normalized = normalizeSessionCookie(sessionCookie);
129
+ if (!normalized) {
130
+ return null;
131
+ }
132
+ return createHash("sha256").update(normalized).digest("hex");
133
+ }
134
+ export function encryptLinkedInSessionCookie(sessionCookie, env = process.env) {
135
+ const normalized = normalizeSessionCookie(sessionCookie);
136
+ const key = getCookieVaultKey(env);
137
+ if (!normalized || !key) {
138
+ return null;
139
+ }
140
+ const iv = randomBytes(12);
141
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
142
+ const ciphertext = Buffer.concat([cipher.update(normalized, "utf8"), cipher.final()]);
143
+ const authTag = cipher.getAuthTag();
144
+ return {
145
+ sessionCookieSha256: sessionCookieHash(normalized),
146
+ sessionCookieCiphertext: ciphertext.toString("base64url"),
147
+ sessionCookieIv: iv.toString("base64url"),
148
+ sessionCookieAuthTag: authTag.toString("base64url")
149
+ };
150
+ }
151
+ export function decryptLinkedInSessionCookie(record, env = process.env) {
152
+ if (!record) {
153
+ return null;
154
+ }
155
+ const key = getCookieVaultKey(env);
156
+ if (!key) {
157
+ return null;
158
+ }
159
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(record.sessionCookieIv, "base64url"));
160
+ decipher.setAuthTag(Buffer.from(record.sessionCookieAuthTag, "base64url"));
161
+ const plaintext = Buffer.concat([
162
+ decipher.update(Buffer.from(record.sessionCookieCiphertext, "base64url")),
163
+ decipher.final()
164
+ ]).toString("utf8");
165
+ return normalizeSessionCookie(plaintext);
166
+ }
167
+ export function resolveCliLinkedInSessionConfig(env = process.env) {
168
+ const manageSessionsSetting = resolveConfiguredEnvValue(env, "SALESPROMPTER_CLI_MANAGE_LINKEDIN_SESSIONS");
169
+ const explicitEnable = isTruthySetting(manageSessionsSetting);
170
+ const explicitDisable = isFalsySetting(manageSessionsSetting);
171
+ const autoEnable = !manageSessionsSetting;
172
+ if (explicitDisable || (!explicitEnable && !autoEnable)) {
173
+ return null;
174
+ }
175
+ const supabaseUrl = resolveConfiguredEnvValue(env, "SALESPROMPTER_SUPABASE_URL") ||
176
+ resolveConfiguredEnvValue(env, "NEXT_PUBLIC_SUPABASE_URL") ||
177
+ "";
178
+ const supabaseServiceRoleKey = resolveConfiguredEnvValue(env, "SUPABASE_SERVICE_ROLE_KEY") || "";
179
+ const hasVaultKey = Boolean(getCookieVaultKey(env));
180
+ const missing = [];
181
+ if (!supabaseUrl) {
182
+ missing.push("SALESPROMPTER_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_URL");
183
+ }
184
+ if (!supabaseServiceRoleKey) {
185
+ missing.push("SUPABASE_SERVICE_ROLE_KEY");
186
+ }
187
+ if (!hasVaultKey) {
188
+ missing.push("LINKEDIN_SESSION_COOKIE_ENCRYPTION_KEY or EXTENSION_AUTH_SECRET or CLERK_SECRET_KEY");
189
+ }
190
+ if (missing.length > 0) {
191
+ if (!explicitEnable) {
192
+ return null;
193
+ }
194
+ throw new Error(`CLI-managed LinkedIn session rotation is enabled, but required configuration is missing: ${missing.join(", ")}. Disable SALESPROMPTER_CLI_MANAGE_LINKEDIN_SESSIONS to use the app-managed export path instead.`);
195
+ }
196
+ return {
197
+ supabaseUrl,
198
+ supabaseServiceRoleKey
199
+ };
200
+ }
201
+ export function createLinkedInSessionSupabaseClient(env = process.env) {
202
+ const config = resolveCliLinkedInSessionConfig(env);
203
+ if (!config) {
204
+ return null;
205
+ }
206
+ return createClient(config.supabaseUrl, config.supabaseServiceRoleKey, {
207
+ auth: { persistSession: false }
208
+ });
209
+ }
210
+ export async function recordLinkedInSessionCookieAudit(supabase, input) {
211
+ const checkedAt = input.checkedAt || new Date().toISOString();
212
+ const updateResult = await supabase
213
+ .from("linkedin_session_cookies")
214
+ .update({
215
+ is_active: input.isActive,
216
+ inactive_reason: input.inactiveReason ?? null,
217
+ last_validation_at: checkedAt,
218
+ last_validation_status: input.isActive ? "active" : "inactive",
219
+ last_feed_status: input.feedStatus,
220
+ last_sales_navigator_status: input.salesNavigatorStatus,
221
+ last_recruiter_status: input.recruiterStatus,
222
+ last_product_class: input.productClass,
223
+ last_validation_error: input.validationError ?? null,
224
+ last_validation_details: input.details ?? {}
225
+ })
226
+ .eq("session_cookie_sha256", input.sessionCookieSha256);
227
+ if (updateResult.error) {
228
+ throw new Error(`Failed to update LinkedIn session cookie status: ${updateResult.error.message}`);
229
+ }
230
+ const insertResult = await supabase.from("linkedin_session_cookie_checks").insert({
231
+ session_cookie_sha256: input.sessionCookieSha256,
232
+ checked_at: checkedAt,
233
+ source: input.source,
234
+ is_active: input.isActive,
235
+ inactive_reason: input.inactiveReason ?? null,
236
+ feed_status: input.feedStatus,
237
+ sales_navigator_status: input.salesNavigatorStatus,
238
+ recruiter_status: input.recruiterStatus,
239
+ product_class: input.productClass,
240
+ validation_error: input.validationError ?? null,
241
+ details: input.details ?? {}
242
+ });
243
+ if (insertResult.error) {
244
+ throw new Error(`Failed to insert LinkedIn session cookie audit row: ${insertResult.error.message}`);
245
+ }
246
+ }
247
+ function lookbackThresholdIso(hours) {
248
+ return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
249
+ }
250
+ function normalizeCandidateSelectionError(error) {
251
+ return error instanceof Error ? error.message : String(error);
252
+ }
253
+ async function listLinkedInSessionCookieCandidates(supabase, options) {
254
+ const query = supabase
255
+ .from("linkedin_session_cookies")
256
+ .select([
257
+ "session_cookie_sha256",
258
+ "session_cookie_ciphertext",
259
+ "session_cookie_iv",
260
+ "session_cookie_auth_tag",
261
+ "last_user_email",
262
+ "last_user_handle",
263
+ "is_active",
264
+ "inactive_reason",
265
+ "last_validation_at",
266
+ "last_selected_at"
267
+ ].join(","))
268
+ .eq("last_product_class", "sales_navigator")
269
+ .eq("last_sales_navigator_status", "ok")
270
+ .not("last_validation_at", "is", null)
271
+ .gte("last_validation_at", lookbackThresholdIso(ACTIVE_LINKEDIN_SESSION_LOOKBACK_HOURS))
272
+ .order("last_selected_at", { ascending: true, nullsFirst: true })
273
+ .order("last_validation_at", { ascending: false })
274
+ .limit(options.limit ?? MAX_RECOVERABLE_LINKEDIN_SESSION_CANDIDATES);
275
+ const filteredQuery = options.activeOnly
276
+ ? query.eq("is_active", true)
277
+ : query
278
+ .eq("is_active", false)
279
+ .like("inactive_reason", `${RECOVERABLE_LINKEDIN_SESSION_INACTIVE_REASON_PREFIX}%`);
280
+ const result = await filteredQuery;
281
+ if (result.error) {
282
+ throw new Error(`Failed to query LinkedIn session cookie candidates: ${result.error.message}`);
283
+ }
284
+ const rows = (result.data ?? []);
285
+ return rows.filter((row) => !isExcludedLinkedInSessionIdentity({
286
+ userEmail: row.last_user_email ?? null,
287
+ userHandle: row.last_user_handle ?? null
288
+ }, options.exclusions));
289
+ }
290
+ async function buildClaimedLinkedInSessionCookieFromRow(supabase, row, source, exclusions, options) {
291
+ const claimedIdentity = {
292
+ userEmail: row.last_user_email ?? null,
293
+ userHandle: row.last_user_handle ?? null
294
+ };
295
+ if (isExcludedLinkedInSessionIdentity(claimedIdentity, exclusions)) {
296
+ await recordLinkedInSessionCookieAudit(supabase, {
297
+ sessionCookieSha256: row.session_cookie_sha256,
298
+ source: `${source}_excluded_identity`,
299
+ feedStatus: null,
300
+ salesNavigatorStatus: "ok",
301
+ recruiterStatus: null,
302
+ productClass: "sales_navigator",
303
+ isActive: true,
304
+ inactiveReason: null,
305
+ validationError: "excluded_linkedin_session_identity",
306
+ details: {
307
+ userEmail: claimedIdentity.userEmail,
308
+ userHandle: claimedIdentity.userHandle,
309
+ excludedUserEmails: exclusions.userEmails,
310
+ excludedUserHandles: exclusions.userHandles,
311
+ claimStrategy: options?.claimStrategy ?? null
312
+ }
313
+ });
314
+ return null;
315
+ }
316
+ try {
317
+ const sessionCookie = decryptLinkedInSessionCookie({
318
+ sessionCookieSha256: row.session_cookie_sha256,
319
+ sessionCookieCiphertext: row.session_cookie_ciphertext,
320
+ sessionCookieIv: row.session_cookie_iv,
321
+ sessionCookieAuthTag: row.session_cookie_auth_tag
322
+ }, options?.env ?? process.env);
323
+ if (!sessionCookie) {
324
+ throw new Error("Claimed LinkedIn session cookie could not be decrypted");
325
+ }
326
+ return {
327
+ sessionCookieSha256: row.session_cookie_sha256,
328
+ sessionCookie,
329
+ userEmail: claimedIdentity.userEmail,
330
+ userHandle: claimedIdentity.userHandle,
331
+ claimStrategy: options?.claimStrategy,
332
+ previousInactiveReason: row.inactive_reason ?? null
333
+ };
334
+ }
335
+ catch (error) {
336
+ await recordLinkedInSessionCookieAudit(supabase, {
337
+ sessionCookieSha256: row.session_cookie_sha256,
338
+ source: `${source}_decrypt_failure`,
339
+ feedStatus: null,
340
+ salesNavigatorStatus: "error",
341
+ recruiterStatus: null,
342
+ productClass: null,
343
+ isActive: false,
344
+ inactiveReason: "decrypt_failed",
345
+ validationError: error instanceof Error ? error.message : String(error),
346
+ details: {
347
+ userEmail: claimedIdentity.userEmail,
348
+ userHandle: claimedIdentity.userHandle,
349
+ claimStrategy: options?.claimStrategy ?? null
350
+ }
351
+ });
352
+ return null;
353
+ }
354
+ }
355
+ async function claimLinkedInSessionCookieFromCandidates(supabase, source, exclusions, options) {
356
+ const candidates = await listLinkedInSessionCookieCandidates(supabase, {
357
+ activeOnly: options.activeOnly,
358
+ exclusions
359
+ });
360
+ for (const candidate of candidates) {
361
+ const claimed = await buildClaimedLinkedInSessionCookieFromRow(supabase, candidate, source, exclusions, {
362
+ claimStrategy: options.claimStrategy,
363
+ env: options.env
364
+ });
365
+ if (claimed) {
366
+ return claimed;
367
+ }
368
+ }
369
+ return null;
370
+ }
371
+ export async function claimActiveLinkedInSessionCookie(supabase, source, env = process.env) {
372
+ if (typeof supabase.rpc !== "function") {
373
+ throw new Error("Supabase client does not support RPC");
374
+ }
375
+ const exclusions = resolveCliLinkedInSessionExclusions(env);
376
+ let lastClaimError = null;
377
+ for (let attempt = 0; attempt < 10; attempt += 1) {
378
+ try {
379
+ const result = await supabase
380
+ .rpc("claim_active_linkedin_session_cookie_excluding", {
381
+ selection_source: source,
382
+ excluded_user_emails: exclusions.userEmails,
383
+ excluded_user_handles: exclusions.userHandles
384
+ })
385
+ .single();
386
+ if (result.error) {
387
+ lastClaimError = result.error.message;
388
+ break;
389
+ }
390
+ const data = result.data;
391
+ if (!data) {
392
+ lastClaimError = "No active LinkedIn session cookies available";
393
+ break;
394
+ }
395
+ const claimed = await buildClaimedLinkedInSessionCookieFromRow(supabase, data, source, exclusions, {
396
+ claimStrategy: "rpc_exclusion_rotation",
397
+ env
398
+ });
399
+ if (claimed) {
400
+ return claimed;
401
+ }
402
+ }
403
+ catch (error) {
404
+ lastClaimError = normalizeCandidateSelectionError(error);
405
+ break;
406
+ }
407
+ }
408
+ const directFallback = await claimLinkedInSessionCookieFromCandidates(supabase, source, exclusions, {
409
+ activeOnly: true,
410
+ claimStrategy: "direct_active_fallback",
411
+ env
412
+ });
413
+ if (directFallback) {
414
+ return directFallback;
415
+ }
416
+ if (lastClaimError &&
417
+ /claim_active_linkedin_session_cookie_excluding/i.test(lastClaimError)) {
418
+ throw new Error("LinkedIn session exclusion rotation RPC is unavailable. Apply the Salesprompter app migration that adds claim_active_linkedin_session_cookie_excluding before running Sales Navigator exports.");
419
+ }
420
+ if (lastClaimError) {
421
+ throw new Error(`Failed to claim LinkedIn session cookie: ${lastClaimError}`);
422
+ }
423
+ throw new Error("No non-excluded decryptable active LinkedIn session cookies available");
424
+ }
425
+ async function claimRecoverableInactiveLinkedInSessionCookie(supabase, source, exclusions, env = process.env, seenCookieHashes) {
426
+ const candidates = await listLinkedInSessionCookieCandidates(supabase, {
427
+ activeOnly: false,
428
+ exclusions
429
+ });
430
+ for (const candidate of candidates) {
431
+ if (seenCookieHashes?.has(candidate.session_cookie_sha256)) {
432
+ continue;
433
+ }
434
+ const claimed = await buildClaimedLinkedInSessionCookieFromRow(supabase, candidate, source, exclusions, {
435
+ claimStrategy: "recoverable_inactive_fallback",
436
+ env
437
+ });
438
+ if (claimed) {
439
+ return claimed;
440
+ }
441
+ }
442
+ return null;
443
+ }
444
+ async function describeLinkedInSessionPool(supabase, exclusions) {
445
+ const result = await supabase
446
+ .from("linkedin_session_cookies")
447
+ .select([
448
+ "last_user_email",
449
+ "last_user_handle",
450
+ "is_active",
451
+ "inactive_reason",
452
+ "last_validation_at",
453
+ "last_sales_navigator_status",
454
+ "last_product_class"
455
+ ].join(","))
456
+ .eq("last_product_class", "sales_navigator");
457
+ if (result.error) {
458
+ throw new Error(`Failed to inspect LinkedIn session pool: ${result.error.message}`);
459
+ }
460
+ const rows = (result.data ?? []).filter((row) => !isExcludedLinkedInSessionIdentity({
461
+ userEmail: row.last_user_email ?? null,
462
+ userHandle: row.last_user_handle ?? null
463
+ }, exclusions));
464
+ const recentThresholdMs = Date.now() - ACTIVE_LINKEDIN_SESSION_LOOKBACK_HOURS * 60 * 60 * 1000;
465
+ const recentRows = rows.filter((row) => {
466
+ if (!row.last_validation_at) {
467
+ return false;
468
+ }
469
+ const timestamp = Date.parse(row.last_validation_at);
470
+ return Number.isFinite(timestamp) && timestamp >= recentThresholdMs;
471
+ });
472
+ const eligibleActive = recentRows.filter((row) => row.is_active === true && row.last_sales_navigator_status === "ok").length;
473
+ const recoverableInactive = recentRows.filter((row) => row.is_active === false &&
474
+ row.last_sales_navigator_status === "ok" &&
475
+ Boolean(row.inactive_reason?.startsWith(RECOVERABLE_LINKEDIN_SESSION_INACTIVE_REASON_PREFIX))).length;
476
+ const recentStatusBreakdown = recentRows.reduce((accumulator, row) => {
477
+ const key = row.last_sales_navigator_status ?? "unknown";
478
+ accumulator[key] = (accumulator[key] ?? 0) + 1;
479
+ return accumulator;
480
+ }, {});
481
+ return {
482
+ totalNonExcludedSalesNavigatorCookies: rows.length,
483
+ recentNonExcludedSalesNavigatorCookies: recentRows.length,
484
+ eligibleActive,
485
+ recoverableInactive,
486
+ recentStatusBreakdown
487
+ };
488
+ }
489
+ async function buildLinkedInSessionPoolDiagnosticsMessage(supabase, exclusions) {
490
+ try {
491
+ const summary = await describeLinkedInSessionPool(supabase, exclusions);
492
+ const breakdown = Object.entries(summary.recentStatusBreakdown)
493
+ .sort(([left], [right]) => left.localeCompare(right))
494
+ .map(([status, count]) => `${status}=${count}`)
495
+ .join(", ");
496
+ return ` LinkedIn session pool after exclusions: ${summary.totalNonExcludedSalesNavigatorCookies} sales-nav cookies, ${summary.recentNonExcludedSalesNavigatorCookies} recently validated, ${summary.eligibleActive} eligible active, ${summary.recoverableInactive} recoverable inactive${breakdown ? `; recent statuses: ${breakdown}` : ""}.`;
497
+ }
498
+ catch (error) {
499
+ return ` LinkedIn session pool diagnostics unavailable: ${normalizeCandidateSelectionError(error)}.`;
500
+ }
501
+ }
502
+ function extractTitle(body) {
503
+ const match = body.match(/<title[^>]*>([^<]+)<\/title>/i);
504
+ return match ? match[1].trim().replace(/\s+/g, " ") : null;
505
+ }
506
+ function normalizeLiAtCookie(sessionCookie) {
507
+ const trimmed = sessionCookie.trim();
508
+ return trimmed.startsWith("li_at=") ? trimmed.slice("li_at=".length) : trimmed;
509
+ }
510
+ function includesAny(value, patterns) {
511
+ const normalized = value ?? "";
512
+ return patterns.some((pattern) => pattern.test(normalized));
513
+ }
514
+ function isLoggedOut(result) {
515
+ return (includesAny(result.finalUrl, [/linkedin\.com\/login/i, /uas\/login/i, /checkpoint\/challenge/i]) ||
516
+ includesAny(result.title, [/login/i, /sign in/i]) ||
517
+ includesAny(result.body, [/session has expired/i, /sign in to linkedin/i, /checkpoint\/challenge/i]));
518
+ }
519
+ function isUpsell(result) {
520
+ return includesAny(result.body, [
521
+ /unlock sales navigator/i,
522
+ /find the right prospects/i,
523
+ /start your free trial/i,
524
+ /guest_login_sales_nav/i,
525
+ /premium\/products\/sales/i,
526
+ /you don'?t have a sales navigator account/i
527
+ ]);
528
+ }
529
+ function hasSearchEntitlementSignals(result) {
530
+ const inSalesSurface = includesAny(result.finalUrl, [/\/sales\/search\/people/i, /\/sales\/home/i]);
531
+ if (!inSalesSurface) {
532
+ return false;
533
+ }
534
+ return (includesAny(result.title, [/sales navigator/i, /lead search/i]) ||
535
+ includesAny(result.body, [
536
+ /sales navigator/i,
537
+ /lead search/i,
538
+ /saved searches/i,
539
+ /personas/i,
540
+ /viewallfilters=true/i,
541
+ /current job title/i,
542
+ /company headcount/i,
543
+ /search keywords/i,
544
+ /sales\/search\/people/i,
545
+ /accounts<\/span>/i,
546
+ /leads<\/span>/i,
547
+ /messaging<\/span>/i
548
+ ]));
549
+ }
550
+ export async function fetchHtmlWithSessionCookie(url, sessionCookie, options) {
551
+ const normalizedSessionCookie = normalizeLiAtCookie(sessionCookie);
552
+ const redirectChain = [];
553
+ let currentUrl = url;
554
+ const seenUrls = new Set([url]);
555
+ for (let index = 0; index < 5; index += 1) {
556
+ const controller = new AbortController();
557
+ const timeout = setTimeout(() => controller.abort(), options?.timeoutMs ?? REQUEST_TIMEOUT_MS);
558
+ try {
559
+ const response = await fetch(currentUrl, {
560
+ method: "GET",
561
+ redirect: "manual",
562
+ signal: controller.signal,
563
+ headers: {
564
+ Cookie: `li_at=${normalizedSessionCookie}`,
565
+ "User-Agent": options?.userAgent ?? DEFAULT_USER_AGENT,
566
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
567
+ "Accept-Language": "en-US,en;q=0.9",
568
+ "Cache-Control": "no-cache",
569
+ Pragma: "no-cache"
570
+ }
571
+ });
572
+ clearTimeout(timeout);
573
+ const location = response.headers.get("location");
574
+ if (location && [301, 302, 303, 307, 308].includes(response.status)) {
575
+ const nextUrl = new URL(location, currentUrl).toString();
576
+ redirectChain.push({
577
+ status: response.status,
578
+ location: nextUrl
579
+ });
580
+ if (seenUrls.has(nextUrl)) {
581
+ return {
582
+ status: response.status,
583
+ finalUrl: nextUrl,
584
+ redirectChain,
585
+ title: null,
586
+ body: "",
587
+ error: "redirect_loop"
588
+ };
589
+ }
590
+ seenUrls.add(nextUrl);
591
+ currentUrl = nextUrl;
592
+ continue;
593
+ }
594
+ const body = await response.text();
595
+ return {
596
+ status: response.status,
597
+ finalUrl: currentUrl,
598
+ redirectChain,
599
+ title: extractTitle(body),
600
+ body,
601
+ error: null
602
+ };
603
+ }
604
+ catch (error) {
605
+ clearTimeout(timeout);
606
+ return {
607
+ status: null,
608
+ finalUrl: currentUrl,
609
+ redirectChain,
610
+ title: null,
611
+ body: "",
612
+ error: error instanceof Error
613
+ ? error.name === "AbortError"
614
+ ? "timeout"
615
+ : error.message
616
+ : String(error)
617
+ };
618
+ }
619
+ }
620
+ return {
621
+ status: null,
622
+ finalUrl: currentUrl,
623
+ redirectChain,
624
+ title: null,
625
+ body: "",
626
+ error: "too_many_redirects"
627
+ };
628
+ }
629
+ export async function probeSalesNavigatorSearchSession(sessionCookie, queryUrl, options) {
630
+ const result = await fetchHtmlWithSessionCookie(queryUrl, sessionCookie, {
631
+ timeoutMs: options?.timeoutMs
632
+ });
633
+ let status = "unknown";
634
+ if (result.error) {
635
+ status = "error";
636
+ }
637
+ else if (result.status === 999 || result.status === 429) {
638
+ status = "rate_limited";
639
+ }
640
+ else if (isLoggedOut(result)) {
641
+ status = "logged_out";
642
+ }
643
+ else if (isUpsell(result)) {
644
+ status = "upsell";
645
+ }
646
+ else if (hasSearchEntitlementSignals(result)) {
647
+ status = "ok";
648
+ }
649
+ else if (includesAny(result.finalUrl, [/\/sales\//i])) {
650
+ status = "unknown";
651
+ }
652
+ const validationError = status === "ok"
653
+ ? null
654
+ : result.error ??
655
+ (status === "logged_out"
656
+ ? "linkedin_session_invalid"
657
+ : status === "upsell"
658
+ ? "sales_navigator_upsell_detected"
659
+ : status === "rate_limited"
660
+ ? "linkedin_rate_limited"
661
+ : "sales_navigator_search_probe_failed");
662
+ return {
663
+ status,
664
+ finalUrl: result.finalUrl,
665
+ title: result.title,
666
+ redirectChain: result.redirectChain,
667
+ validationError,
668
+ details: {
669
+ statusCode: result.status,
670
+ error: result.error,
671
+ redirectCount: result.redirectChain.length,
672
+ queryUrl
673
+ }
674
+ };
675
+ }
676
+ export async function claimValidatedSalesNavigatorSessionCookieForCli(options) {
677
+ const env = options.env ?? process.env;
678
+ const supabase = options.supabase ?? createLinkedInSessionSupabaseClient(env);
679
+ if (!supabase) {
680
+ return null;
681
+ }
682
+ const seenCookieHashes = new Set();
683
+ const startedAt = Date.now();
684
+ let lastFailureMessage = null;
685
+ let attempts = 0;
686
+ const exclusions = resolveCliLinkedInSessionExclusions(env);
687
+ for (let attempt = 0; attempt < MAX_COOKIE_VALIDATION_ATTEMPTS; attempt += 1) {
688
+ if (Date.now() - startedAt >= COOKIE_VALIDATION_TIMEOUT_MS) {
689
+ break;
690
+ }
691
+ let claimedSession = null;
692
+ let claimErrorMessage = null;
693
+ try {
694
+ claimedSession = await claimActiveLinkedInSessionCookie(supabase, options.source, env);
695
+ }
696
+ catch (error) {
697
+ claimErrorMessage = normalizeCandidateSelectionError(error);
698
+ }
699
+ if (!claimedSession) {
700
+ claimedSession = await claimRecoverableInactiveLinkedInSessionCookie(supabase, options.source, exclusions, env, seenCookieHashes);
701
+ }
702
+ if (!claimedSession) {
703
+ lastFailureMessage =
704
+ claimErrorMessage ?? "No non-excluded decryptable active LinkedIn session cookies available";
705
+ break;
706
+ }
707
+ attempts += 1;
708
+ if (seenCookieHashes.has(claimedSession.sessionCookieSha256)) {
709
+ break;
710
+ }
711
+ seenCookieHashes.add(claimedSession.sessionCookieSha256);
712
+ const probe = await probeSalesNavigatorSearchSession(claimedSession.sessionCookie, options.queryUrl, {
713
+ timeoutMs: COOKIE_PROBE_TIMEOUT_MS
714
+ });
715
+ const shouldDeactivate = probe.status === "logged_out" || probe.status === "upsell";
716
+ await recordLinkedInSessionCookieAudit(supabase, {
717
+ sessionCookieSha256: claimedSession.sessionCookieSha256,
718
+ source: `${options.source}_preflight`,
719
+ feedStatus: null,
720
+ salesNavigatorStatus: probe.status,
721
+ recruiterStatus: null,
722
+ productClass: probe.status === "ok" ? "sales_navigator" : null,
723
+ isActive: !shouldDeactivate,
724
+ inactiveReason: shouldDeactivate
725
+ ? probe.status === "upsell"
726
+ ? "salesnav_search_upsell_detected"
727
+ : "linkedin_session_invalid"
728
+ : null,
729
+ validationError: probe.validationError,
730
+ details: {
731
+ attemptNumber: attempts,
732
+ validationElapsedMs: Date.now() - startedAt,
733
+ claimStrategy: claimedSession.claimStrategy ?? null,
734
+ previousInactiveReason: claimedSession.previousInactiveReason ?? null,
735
+ ...probe.details,
736
+ finalUrl: probe.finalUrl,
737
+ title: probe.title,
738
+ redirectChain: probe.redirectChain.slice(0, 5)
739
+ }
740
+ });
741
+ if (probe.status === "ok") {
742
+ return claimedSession;
743
+ }
744
+ lastFailureMessage =
745
+ probe.validationError ?? `Sales Navigator search probe returned ${probe.status}`;
746
+ }
747
+ const poolDiagnostics = await buildLinkedInSessionPoolDiagnosticsMessage(supabase, exclusions);
748
+ throw new Error(lastFailureMessage
749
+ ? `No validated LinkedIn Sales Navigator session cookies available after ${attempts} preflight attempt${attempts === 1 ? "" : "s"}: ${lastFailureMessage}${poolDiagnostics}`
750
+ : `No validated LinkedIn Sales Navigator session cookies available after ${attempts} preflight attempt${attempts === 1 ? "" : "s"}${poolDiagnostics}`);
751
+ }